@stardustlc/dsh-dream 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -2,9 +2,12 @@
2
2
  // 仓库里没有 src/client.ts,pnpm run build 也不会重写它。改面板请直接改这里,
3
3
  // 改完用 node --check lib/client.js 自检(CI 里也会跑这一步)。
4
4
  //
5
- // 面板只做一件事:把梦境日记(dreams.jsonl)画成人能看懂的样子 —— 概览、心境方块、
6
- // 教训榜、按时间倒序的梦卡片。数据来自插件自己的同源路由
7
- // GET /_dsh/dsh-dream/journal(由 src/index.ts 挂载);前端不缓存业务数据,刷新即见。
5
+ // 面板做两件事:把梦境日记(dreams.jsonl)画成人能看懂的样子 —— 概览、心境方块、
6
+ // 教训榜、按时间倒序的梦卡片;再只读展示 M1 的技术经验(<journalDir>/knowledge/)。
7
+ // 数据来自插件自己的同源只读路由 GET /_dsh/dsh-dream/journal 与
8
+ // GET /_dsh/dsh-dream/knowledge(由 src/index.ts 挂载);前端不缓存业务数据,刷新即见。
9
+ // 经验区块只展示状态 / 条件 / 范围 / 适用与例外 / 证据摘要 / 最近核验;日志截断或坏行会明确提示,
10
+ // 不展示权重、向量或原始 JSON。
8
11
  // 隐私纪律:面板只读,不写文件、不上传;「隐私模式」只做显示层模糊,DOM 仍是明文。
9
12
  window.__ModuleLoader__.load({ id: "@stardustlc/dsh-dream", factory: (require) => {
10
13
  var module = { exports: {} }; var exports = module.exports;
@@ -16,6 +19,9 @@ const h = React.createElement;
16
19
 
17
20
  // 相对路径按 document.baseURI 解析:反代挂在 /tools/dsh/ 这类前缀下也不会打偏。
18
21
  const ROUTE = "_dsh/dsh-dream/journal";
22
+ const KNOWLEDGE_ROUTE = "_dsh/dsh-dream/knowledge";
23
+ const KNOWLEDGE_LIMIT = 50;
24
+ const KNOWLEDGE_STATES = ["candidate", "usable", "disputed", "stale", "rejected"];
19
25
  const DEFAULT_LIMIT = 50;
20
26
  const MORE_LIMIT = 500;
21
27
  const FETCH_TIMEOUT_MS = 15000;
@@ -35,9 +41,9 @@ function timestampOf(iso) {
35
41
  }
36
42
 
37
43
  /** 路由 URL:按 document.baseURI 解析,兼容反代前缀;环境不齐时退回相对路径。 */
38
- function routeUrl(query) {
44
+ function routeUrlFor(route, query) {
39
45
  const suffix = query === "" || query === undefined || query === null ? "" : "?" + query;
40
- const relative = ROUTE + suffix;
46
+ const relative = route + suffix;
41
47
  try {
42
48
  let base = null;
43
49
  if (typeof document !== "undefined" && document !== null && typeof document.baseURI === "string" && document.baseURI !== "") {
@@ -52,6 +58,11 @@ function routeUrl(query) {
52
58
  }
53
59
  }
54
60
 
61
+ /** 梦境日记路由的快捷方式(保持既有调用语义)。 */
62
+ function routeUrl(query) {
63
+ return routeUrlFor(ROUTE, query);
64
+ }
65
+
55
66
  function errorMessageOf(body, status) {
56
67
  if (body !== null && typeof body === "object") {
57
68
  if (body.error !== null && typeof body.error === "object" && typeof body.error.message === "string" && body.error.message !== "") return body.error.message;
@@ -87,6 +98,29 @@ async function fetchJournal(query, limit) {
87
98
  };
88
99
  }
89
100
 
101
+ /** GET /_dsh/dsh-dream/knowledge:只读契约体 { lessons, evidence, stats },无查询参数。 */
102
+ async function fetchKnowledge() {
103
+ const init = { credentials: "same-origin" };
104
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") init.signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
105
+ const res = await fetch(routeUrlFor(KNOWLEDGE_ROUTE, ""), init);
106
+ let body = null;
107
+ try { body = await res.json(); } catch (error) { body = null; }
108
+ if (body === null || typeof body !== "object") throw new Error("服务器返回的不是 JSON(HTTP " + String(res.status) + ")");
109
+ if (res.ok !== true || body.ok === false) {
110
+ const failure = new Error(errorMessageOf(body, res.status));
111
+ failure.status = res.status;
112
+ failure.code = body.error !== null && typeof body.error === "object" ? body.error.code : undefined;
113
+ throw failure;
114
+ }
115
+ // 契约体就是 { lessons, evidence, stats };万一被包进 { ok, value } 信封也认。
116
+ const data = body.value !== null && typeof body.value === "object" ? body.value : body;
117
+ return {
118
+ lessons: Array.isArray(data.lessons) ? data.lessons : [],
119
+ evidence: Array.isArray(data.evidence) ? data.evidence : [],
120
+ stats: data.stats !== null && typeof data.stats === "object" ? data.stats : null,
121
+ };
122
+ }
123
+
90
124
  function normalizeDreams(value, limit) {
91
125
  if (!Array.isArray(value) || limit <= 0) return [];
92
126
  const out = [];
@@ -157,6 +191,150 @@ function normalizeStats(value, dreams) {
157
191
  return { total: total, moods: moods, topLessons: topLessons };
158
192
  }
159
193
 
194
+ /** 只认契约里的 5 个状态;未知状态不冒充候选。 */
195
+ function normalizeLessonState(value) {
196
+ return KNOWLEDGE_STATES.indexOf(value) !== -1 ? value : "unknown";
197
+ }
198
+
199
+ /** 状态 → 中文标签(面板文案集中在这里)。 */
200
+ function lessonStateLabel(state) {
201
+ switch (state) {
202
+ case "candidate": return "候选(未采纳)";
203
+ case "usable": return "可用";
204
+ case "disputed": return "有冲突";
205
+ case "stale": return "待复核";
206
+ case "rejected": return "已驳回";
207
+ default: return "未知状态";
208
+ }
209
+ }
210
+
211
+ /** 状态 → 视觉类名:candidate 与 disputed 必须不同色,其余状态各自可辨。 */
212
+ function lessonStateClass(state) {
213
+ switch (state) {
214
+ case "candidate": return "dshd-kstate-candidate";
215
+ case "usable": return "dshd-kstate-usable";
216
+ case "disputed": return "dshd-kstate-disputed";
217
+ case "stale": return "dshd-kstate-stale";
218
+ case "rejected": return "dshd-kstate-rejected";
219
+ default: return "dshd-kstate-unknown";
220
+ }
221
+ }
222
+
223
+ /** 只取字符串字段并去首尾空白;坏形状降级为 "",不让面板崩。 */
224
+ function trimmedText(value) {
225
+ return typeof value === "string" ? value.trim() : "";
226
+ }
227
+
228
+ /** applicability 数组 → 紧凑适用串(package + versions / platform);坏形状跳过,空数组给 ""。 */
229
+ function applicabilityTextOf(value) {
230
+ if (!Array.isArray(value)) return "";
231
+ const parts = [];
232
+ for (const raw of value) {
233
+ if (raw === null || typeof raw !== "object") continue;
234
+ const pkg = trimmedText(raw.package);
235
+ const versions = trimmedText(raw.versions);
236
+ const platform = trimmedText(raw.platform);
237
+ const main = pkg !== "" ? (versions !== "" ? pkg + " " + versions : pkg) : versions;
238
+ const bits = [];
239
+ if (main !== "") bits.push(main);
240
+ if (platform !== "") bits.push("平台 " + platform);
241
+ if (bits.length > 0) parts.push(bits.join(" · "));
242
+ }
243
+ if (parts.length === 0) return "";
244
+ if (parts.length > 4) return parts.slice(0, 4).join(";") + ";等 " + String(parts.length) + " 条适用范围";
245
+ return parts.join(";");
246
+ }
247
+
248
+ /**
249
+ * 经验视图模型:只投影展示所需字段(title / when / applicability / exceptions /
250
+ * state / scopeLabel / evidenceSummary / lastValidatedAt),不碰权重、向量、原始 JSON。
251
+ * limit 只截断渲染行数,byState 仍按全量统计;truncated / badLines 原样带给提示行。
252
+ */
253
+ function normalizeKnowledge(value, now, limit) {
254
+ const source = value !== null && typeof value === "object" ? value : {};
255
+ const cap = typeof limit === "number" && limit > 0 ? limit : KNOWLEDGE_LIMIT;
256
+ const rawLessons = Array.isArray(source.lessons) ? source.lessons : [];
257
+ const statsSource = source.stats !== null && typeof source.stats === "object" ? source.stats : {};
258
+ const byState = {};
259
+ const rows = [];
260
+ for (const raw of rawLessons) {
261
+ if (raw === null || typeof raw !== "object") continue;
262
+ const title = trimmedText(raw.title);
263
+ const action = trimmedText(raw.action);
264
+ if (title === "" && action === "") continue;
265
+ const state = normalizeLessonState(raw.state);
266
+ byState[state] = (byState[state] || 0) + 1;
267
+ if (rows.length >= cap) continue;
268
+ const lastValidatedAt = trimmedText(raw.lastValidatedAt);
269
+ rows.push({
270
+ id: trimmedText(raw.id),
271
+ title: title !== "" ? title : action,
272
+ when: trimmedText(raw.when),
273
+ applicability: applicabilityTextOf(raw.applicability),
274
+ exceptions: Array.isArray(raw.exceptions)
275
+ ? raw.exceptions.filter((item) => typeof item === "string" && item.trim() !== "").map((item) => item.trim()).slice(0, 10)
276
+ : [],
277
+ state: state,
278
+ stateLabel: lessonStateLabel(state),
279
+ stateClass: lessonStateClass(state),
280
+ scopeLabel: trimmedText(raw.scopeLabel) || "范围未标注",
281
+ evidenceSummary: trimmedText(raw.evidenceSummary) || "暂无可展示的证据摘要",
282
+ lastValidatedAt: lastValidatedAt,
283
+ lastText: lastValidatedAt === "" ? "尚未核验" : relativeTime(lastValidatedAt, now),
284
+ lastTitle: lastValidatedAt === "" ? "没有 lastValidatedAt(从未核验)" : formatDate(lastValidatedAt) + (formatTime(lastValidatedAt) === "" ? "" : " " + formatTime(lastValidatedAt)),
285
+ candidate: state === "candidate",
286
+ disputed: state === "disputed",
287
+ });
288
+ }
289
+ const total = typeof statsSource.lessons === "number" && statsSource.lessons >= 0 ? statsSource.lessons : rawLessons.length;
290
+ const evidenceTotal = typeof statsSource.evidence === "number" && statsSource.evidence >= 0 ? statsSource.evidence : 0;
291
+ const badLinesRaw = statsSource.badLines;
292
+ const badLines = typeof badLinesRaw === "number" && Number.isFinite(badLinesRaw) && badLinesRaw > 0 ? Math.floor(badLinesRaw) : 0;
293
+ return {
294
+ rows: rows,
295
+ total: total,
296
+ shown: rows.length,
297
+ stats: { lessons: total, evidence: evidenceTotal, byState: byState, truncated: statsSource.truncated === true, badLines: badLines },
298
+ };
299
+ }
300
+
301
+ /** 经验区块的统计文案:按状态计数,不包装成可靠度。 */
302
+ function knowledgeCountText(stats) {
303
+ const source = stats !== null && typeof stats === "object" ? stats : {};
304
+ const byState = source.byState !== null && typeof source.byState === "object" ? source.byState : {};
305
+ const parts = [];
306
+ for (const state of KNOWLEDGE_STATES) {
307
+ const count = typeof byState[state] === "number" && byState[state] > 0 ? byState[state] : 0;
308
+ if (count > 0) parts.push(lessonStateLabel(state) + " " + String(count));
309
+ }
310
+ if (parts.length === 0) {
311
+ const total = typeof source.lessons === "number" && source.lessons > 0 ? source.lessons : 0;
312
+ return total > 0 ? "共 " + String(total) + " 条" : "还没有经验";
313
+ }
314
+ return parts.join(" · ");
315
+ }
316
+
317
+ /** 截断 / 坏行提示:无问题时给 "";确保面板不静默展示不完整的经验集。 */
318
+ function knowledgeNoticeText(stats) {
319
+ const source = stats !== null && typeof stats === "object" ? stats : {};
320
+ const truncated = source.truncated === true;
321
+ const badLines = typeof source.badLines === "number" && Number.isFinite(source.badLines) && source.badLines > 0 ? Math.floor(source.badLines) : 0;
322
+ if (truncated !== true && badLines === 0) return "";
323
+ const parts = [];
324
+ if (truncated === true) parts.push("日志过大,仅回放最近一段事件");
325
+ if (badLines > 0) parts.push("有 " + String(badLines) + " 行损坏已跳过");
326
+ return parts.join(";") + ";events.jsonl 未改动。";
327
+ }
328
+
329
+ /** 经验区块的独立空态:与「还没有梦境日记」分开,说明经验从哪来。 */
330
+ function knowledgeEmptyState() {
331
+ return {
332
+ title: "还没有经验记录",
333
+ body: "经验是带证据、有适用条件的技术结论:收尾时用 dream_learn 提交候选,证据足够、范围明确的会出现在这里。",
334
+ hint: "旧日记里的教训不会自动升级为经验;采纳、驳回或标记冲突请用 dream_review。",
335
+ };
336
+ }
337
+
160
338
  function spanOf(dreams) {
161
339
  let min = null;
162
340
  let max = null;
@@ -483,6 +661,78 @@ function LessonCard(props) {
483
661
  ]);
484
662
  }
485
663
 
664
+ function KnowledgeRow(props) {
665
+ const row = props.row;
666
+ const privacy = props.privacy === true;
667
+ const [peek, setPeek] = useState(false);
668
+ const reveal = privacy === true ? function () { setPeek(peek !== true); } : undefined;
669
+ return h("div", {
670
+ className: "dshd-knowledge " + row.stateClass + (privacy === true && peek === true ? " dshd-peek" : ""),
671
+ onClick: reveal,
672
+ title: privacy === true ? "隐私模式:点击此条临时查看" : undefined,
673
+ }, [
674
+ h("div", { className: "dshd-khead", key: "h" }, [
675
+ h("span", { className: "dshd-kbadge", key: "b" }, row.stateLabel),
676
+ h("span", { className: "dshd-ktitle", key: "t", title: row.title }, row.title),
677
+ ]),
678
+ h("div", { className: "dshd-kmeta", key: "m" }, [
679
+ h("span", { className: "dshd-kscope", key: "s", title: "适用范围" }, row.scopeLabel),
680
+ h("span", { className: "dshd-klast", key: "l", title: row.lastTitle }, "最近核验:" + row.lastText),
681
+ ]),
682
+ h("div", { className: "dshd-kbody", key: "body" }, [
683
+ h("div", { key: "when" }, "何时用:" + (row.when !== "" ? row.when : "未标注适用条件")),
684
+ row.applicability !== "" ? h("div", { key: "applicability", title: row.applicability }, "适用:" + row.applicability) : null,
685
+ row.exceptions.length > 0 ? h("div", { key: "exceptions", title: row.exceptions.join(";") }, "例外:" + row.exceptions.join(";")) : null,
686
+ h("div", { key: "evidence" }, "证据:" + row.evidenceSummary),
687
+ ]),
688
+ ]);
689
+ }
690
+
691
+ function KnowledgeCard(props) {
692
+ const vm = props.vm;
693
+ const notice = knowledgeNoticeText(vm === null || vm === undefined ? null : vm.stats);
694
+ const noticeNode = notice === "" ? null : h("div", { className: "dshd-knotice", key: "notice", role: "status" }, notice);
695
+ const head = function (extra) {
696
+ return h("div", { className: "dshd-khead", key: "head" }, [
697
+ h("h4", { key: "t" }, "经验"),
698
+ h("span", { className: "dshd-spacer", key: "s" }),
699
+ extra,
700
+ ]);
701
+ };
702
+ if (props.error !== null && props.error !== undefined) {
703
+ return h("section", { className: "dshd-card dshd-kcard" }, [
704
+ head(h(Btn, { key: "retry", onClick: props.onRetry, disabled: props.loading === true }, "重试")),
705
+ h("div", { className: "dshd-hint", key: "e", role: "alert" }, "读取经验失败:" + (props.error.message === "" ? String(props.error) : props.error.message)),
706
+ h("div", { className: "dshd-note", key: "n" }, "旧日记不受影响;修好后点「重试」。"),
707
+ ]);
708
+ }
709
+ if (props.empty === true) {
710
+ const empty = knowledgeEmptyState();
711
+ return h("section", { className: "dshd-card dshd-kcard" }, [
712
+ h("h4", { key: "t" }, "经验"),
713
+ noticeNode,
714
+ h("div", { className: "dshd-kempty", key: "e" }, [
715
+ h("div", { className: "dshd-kempty-title", key: "t2" }, empty.title),
716
+ h("p", { className: "dshd-kempty-body", key: "b" }, empty.body),
717
+ h("p", { className: "dshd-note", key: "h" }, empty.hint),
718
+ ]),
719
+ ]);
720
+ }
721
+ return h("section", { className: "dshd-card dshd-kcard" }, [
722
+ head(props.loading === true
723
+ ? h("span", { className: "dshd-hint", key: "loading" }, "加载中…")
724
+ : h("span", { className: "dshd-hint", key: "count" }, knowledgeCountText(vm.stats))),
725
+ noticeNode,
726
+ h("div", { className: "dshd-krows", key: "rows" }, vm.rows.map(function (row, index) {
727
+ return h(KnowledgeRow, { key: row.id !== "" ? row.id : "k" + String(index), row: row, privacy: props.privacy === true });
728
+ })),
729
+ vm.shown < vm.total
730
+ ? h("div", { className: "dshd-note", key: "more" }, "只显示最近 " + String(vm.shown) + " / " + String(vm.total) + " 条经验。")
731
+ : null,
732
+ h("div", { className: "dshd-note", key: "note" }, "只读展示:候选可被检索到,但默认不注入任务建议、且排在可用经验之后;未经审阅不得当作已证实结论。采纳、驳回或标记冲突请用 dream_review。"),
733
+ ]);
734
+ }
735
+
486
736
  function DreamCard(props) {
487
737
  const dream = props.dream;
488
738
  const privacy = props.privacy === true;
@@ -561,6 +811,9 @@ function DreamPanel() {
561
811
  const [filters, setFilters] = useState({ moods: [], withLessons: false });
562
812
  const [filterOpen, setFilterOpen] = useState(false);
563
813
  const [limit, setLimit] = useState(DEFAULT_LIMIT);
814
+ const [knowledge, setKnowledge] = useState(null);
815
+ const [knowledgeLoading, setKnowledgeLoading] = useState(true);
816
+ const [knowledgeError, setKnowledgeError] = useState(null);
564
817
 
565
818
  // 搜索防抖:连续打字只发一次请求;回车立即生效。
566
819
  useEffect(function () {
@@ -587,7 +840,26 @@ function DreamPanel() {
587
840
  return function () { alive = false; };
588
841
  }, [query, reloadToken]);
589
842
 
843
+ // 经验区块独立取数:知识路由挂了但日记读不出来时,经验仍能显示(反之亦然)。
844
+ useEffect(function () {
845
+ let alive = true;
846
+ setKnowledgeLoading(true);
847
+ setKnowledgeError(null);
848
+ fetchKnowledge().then(function (payload) {
849
+ if (alive !== true) return;
850
+ setKnowledge(normalizeKnowledge(payload, new Date()));
851
+ setKnowledgeLoading(false);
852
+ }).catch(function (failure) {
853
+ if (alive !== true) return;
854
+ setKnowledgeError(failure instanceof Error ? failure : new Error(String(failure)));
855
+ setKnowledgeLoading(false);
856
+ setKnowledge(null);
857
+ });
858
+ return function () { alive = false; };
859
+ }, [reloadToken]);
860
+
590
861
  const vm = viewModelOf(dreams, stats, now);
862
+ const knowledgeVm = knowledge === null ? { rows: [], total: 0, shown: 0, stats: null } : knowledge;
591
863
  const pristine = vm.count === 0 && query === "";
592
864
  const filterCount = filterCountOf(filters);
593
865
  const visibleDreams = applyFilters(dreams, filters);
@@ -612,6 +884,16 @@ function DreamPanel() {
612
884
  };
613
885
  const clearFilters = function () { setFilters({ moods: [], withLessons: false }); };
614
886
  const showMore = function () { if (limit < MORE_LIMIT) setLimit(MORE_LIMIT); };
887
+ const showKnowledge = function () { if (knowledgeLoading !== true) setReloadToken(function (value) { return value + 1; }); };
888
+ const knowledgeCard = h(KnowledgeCard, {
889
+ key: "knowledge",
890
+ vm: knowledgeVm,
891
+ loading: knowledgeLoading === true,
892
+ error: knowledgeError,
893
+ empty: knowledgeError === null && knowledgeLoading !== true && knowledgeVm.rows.length === 0,
894
+ privacy: privacy === true,
895
+ onRetry: showKnowledge,
896
+ });
615
897
 
616
898
  // 筛选 popover:点面板外任意处收起;纯 node 冒烟没有事件系统就跳过。
617
899
  useEffect(function () {
@@ -705,13 +987,16 @@ function DreamPanel() {
705
987
  ]);
706
988
 
707
989
  const body = function () {
708
- if (loading === true && vm.count === 0) return h(Skeleton, { key: "skeleton" });
990
+ if (loading === true && vm.count === 0) return h("div", { key: "boot" }, [h(Skeleton, { key: "skeleton" }), knowledgeCard]);
709
991
  if (vm.count === 0) {
710
- if (error !== null) return h("div", { className: "dshd-empty", key: "failed" }, [
711
- h("h3", { key: "t" }, "暂时读不到梦境日记"),
712
- h("p", { key: "p" }, "等宿主路由就绪后点上面的「重试」;如果一直失败,看一眼 DSH 日志里有没有 [dsh-dream] 告警。"),
992
+ if (error !== null) return h("div", { key: "failed-wrap" }, [
993
+ h("div", { className: "dshd-empty", key: "failed" }, [
994
+ h("h3", { key: "t" }, "暂时读不到梦境日记"),
995
+ h("p", { key: "p" }, "等宿主路由就绪后点上面的「重试」;如果一直失败,看一眼 DSH 日志里有没有 [dsh-dream] 告警。"),
996
+ ]),
997
+ knowledgeCard,
713
998
  ]);
714
- return h(EmptyState, { key: "empty", query: query, onClear: query === "" ? null : clearSearch });
999
+ return h("div", { key: "empty-wrap" }, [h(EmptyState, { key: "empty", query: query, onClear: query === "" ? null : clearSearch }), knowledgeCard]);
715
1000
  }
716
1001
  return h("div", { key: "content" }, [
717
1002
  vm.showTiles
@@ -726,6 +1011,7 @@ function DreamPanel() {
726
1011
  h(MoodCard, { key: "mood", vm: vm }),
727
1012
  h(LessonCard, { key: "lessons", vm: vm }),
728
1013
  ]),
1014
+ knowledgeCard,
729
1015
  h("div", { className: "dshd-listhead", key: "listhead" }, [
730
1016
  h("h4", { key: "t" }, query === "" && filterCount === 0 ? "梦境时间线" : (query === "" ? "筛选结果" : "搜索结果")),
731
1017
  h("span", { className: "dshd-hint", key: "n" }, listHintOf(query, vm.count, filterCount, visibleDreams.length)),
@@ -857,7 +1143,29 @@ const CSS = [
857
1143
  ".dshd-chip{font-family:ui-monospace,Consolas,monospace;font-size:11px;background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.05));border-radius:999px;padding:2px 9px;color:var(--dsw-alias-label-tertiary,#6b7280)}",
858
1144
  ".dshd-skel{height:58px;border-radius:12px;margin-bottom:8px;background:linear-gradient(90deg,var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)) 25%,color-mix(in srgb,var(--dsw-alias-label-primary,#111) 10%,transparent) 37%,var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)) 63%);background-size:400% 100%;animation:dshd-shimmer 1.4s ease infinite}",
859
1145
  "@keyframes dshd-shimmer{0%{background-position:100% 0}100%{background-position:0 0}}",
860
- ".dshd-footer{margin-top:12px;display:flex;align-items:center;gap:8px;justify-content:space-between;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11px;flex-wrap:wrap}"
1146
+ ".dshd-footer{margin-top:12px;display:flex;align-items:center;gap:8px;justify-content:space-between;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11px;flex-wrap:wrap}",
1147
+ ".dshd-kcard{margin:2px 0 14px}",
1148
+ ".dshd-khead{display:flex;align-items:baseline;gap:8px;margin-bottom:8px}",
1149
+ ".dshd-khead h4{margin:0;font-size:13px;font-weight:600}",
1150
+ ".dshd-krows{display:flex;flex-direction:column;gap:7px}",
1151
+ ".dshd-knowledge{border-left:3px solid var(--dsw-alias-label-tertiary,#6b7280);background:var(--dsw-alias-bg-layer-1,#fff);border-radius:9px;padding:8px 10px;min-width:0}",
1152
+ ".dshd-knowledge.dshd-kstate-candidate{border-left-color:var(--dsw-alias-state-warn-primary,#f59e0b)}",
1153
+ ".dshd-knowledge.dshd-kstate-disputed{border-left-color:var(--dsw-alias-state-error-primary,#dc2626);background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#dc2626) 6%,var(--dsw-alias-bg-layer-1,#fff))}",
1154
+ ".dshd-knowledge.dshd-kstate-usable{border-left-color:var(--dsw-alias-state-success-primary,#16a34a)}",
1155
+ ".dshd-knowledge.dshd-kstate-stale,.dshd-knowledge.dshd-kstate-rejected,.dshd-knowledge.dshd-kstate-unknown{border-left-color:var(--dsw-alias-label-tertiary,#6b7280);opacity:.86}",
1156
+ ".dshd-kbadge{flex:0 0 auto;font-size:11px;padding:1px 7px;border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-label-primary,#111) 8%,transparent);white-space:nowrap}",
1157
+ ".dshd-kstate-candidate .dshd-kbadge{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#f59e0b) 22%,transparent)}",
1158
+ ".dshd-kstate-disputed .dshd-kbadge{background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#dc2626) 20%,transparent);color:var(--dsw-alias-state-error-primary,#dc2626)}",
1159
+ ".dshd-kstate-usable .dshd-kbadge{background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#16a34a) 18%,transparent)}",
1160
+ ".dshd-ktitle{font-size:12px;font-weight:600;word-break:break-word}",
1161
+ ".dshd-kmeta{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:var(--dsw-alias-label-tertiary,#6b7280);margin-top:2px}",
1162
+ ".dshd-kbody{margin-top:5px;display:flex;flex-direction:column;gap:3px;font-size:12px;word-break:break-word}",
1163
+ ".dshd-kempty{padding:14px 10px;text-align:center;color:var(--dsw-alias-label-tertiary,#6b7280)}",
1164
+ ".dshd-kempty-title{color:var(--dsw-alias-label-primary,#111);font-weight:600;font-size:13px;margin-bottom:4px}",
1165
+ ".dshd-kempty-body{margin:0 auto 5px;max-width:460px;font-size:12px}",
1166
+ ".dshd-knotice{font-size:11px;color:var(--dsw-alias-label-primary,#111);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#f59e0b) 14%,transparent);border-radius:8px;padding:5px 8px;margin-bottom:7px}",
1167
+ ".dshd-privacy .dshd-kbody{filter:blur(4px)}",
1168
+ ".dshd-privacy .dshd-knowledge.dshd-peek .dshd-kbody{filter:none}"
861
1169
  ].join("");
862
1170
  /* @ui-css-end */
863
1171
 
@@ -897,6 +1205,18 @@ exports.inject = inject;
897
1205
  // 纯函数内部件:验证脚本可直接取来测,不必另抄一份算法。
898
1206
  exports.__internals = {
899
1207
  routeUrl: routeUrl,
1208
+ routeUrlFor: routeUrlFor,
1209
+ KNOWLEDGE_ROUTE: KNOWLEDGE_ROUTE,
1210
+ KNOWLEDGE_LIMIT: KNOWLEDGE_LIMIT,
1211
+ fetchKnowledge: fetchKnowledge,
1212
+ normalizeKnowledge: normalizeKnowledge,
1213
+ normalizeLessonState: normalizeLessonState,
1214
+ applicabilityTextOf: applicabilityTextOf,
1215
+ knowledgeNoticeText: knowledgeNoticeText,
1216
+ lessonStateLabel: lessonStateLabel,
1217
+ lessonStateClass: lessonStateClass,
1218
+ knowledgeCountText: knowledgeCountText,
1219
+ knowledgeEmptyState: knowledgeEmptyState,
900
1220
  fetchJournal: fetchJournal,
901
1221
  normalizeDreams: normalizeDreams,
902
1222
  normalizeStats: normalizeStats,
package/lib/index.d.ts CHANGED
@@ -33,3 +33,6 @@ export * from './journal.js';
33
33
  export * from './tools.js';
34
34
  export * from './mask.js';
35
35
  export * from './bridge.js';
36
+ export * from './knowledge.js';
37
+ export * from './knowledge-store.js';
38
+ export * from './retrieval.js';
package/lib/index.js CHANGED
@@ -71,3 +71,6 @@ export * from './journal.js';
71
71
  export * from './tools.js';
72
72
  export * from './mask.js';
73
73
  export * from './bridge.js';
74
+ export * from './knowledge.js';
75
+ export * from './knowledge-store.js';
76
+ export * from './retrieval.js';
package/lib/journal.d.ts CHANGED
@@ -9,6 +9,19 @@ export interface DreamEntry {
9
9
  export declare function journalFile(journalDir: string): string;
10
10
  /** 追加一条梦;返回落盘后的条目。 */
11
11
  export declare function saveDream(journalDir: string, reflection: string, lessons: string[], mood: string): DreamEntry;
12
+ /** 读取结果:梦境 + 被跳过的行数(坏 JSON 或不是梦境记录)。 */
13
+ export interface DreamReadResult {
14
+ dreams: DreamEntry[];
15
+ skippedLines: number;
16
+ }
17
+ /**
18
+ * 倒序读取梦境并**报告**跳过了多少行。
19
+ *
20
+ * 两个健壮性要求(评审实测发现):
21
+ * - 文件开头的 UTF-8 BOM 必须剥离,否则第一行解析失败会让整份日记读空;
22
+ * - 坏行跳过要可计数,不能静默——调用方(dream_health / 只读路由)可以透出。
23
+ */
24
+ export declare function readDreamsDetailed(journalDir: string, limit: number): DreamReadResult;
12
25
  /** 倒序读取梦境(新梦在前);损坏行跳过。 */
13
26
  export declare function readDreams(journalDir: string, limit: number): DreamEntry[];
14
27
  /** 教训榜的一行:次数与最近一次出现时间(lastAt 缺失时是空串,绝不为 null)。 */
@@ -22,6 +35,8 @@ export interface DreamStats {
22
35
  total: number;
23
36
  moods: Record<string, number>;
24
37
  topLessons: LessonStat[];
38
+ /** 解析时跳过的行数(坏 JSON 或不是梦境记录);0 表示全部可读。 */
39
+ skippedLines: number;
25
40
  }
26
41
  /**
27
42
  * 统计梦境(基于全量日记)。
package/lib/journal.js CHANGED
@@ -26,18 +26,27 @@ export function saveDream(journalDir, reflection, lessons, mood) {
26
26
  }
27
27
  /** 需要读全量日记时的上限:检索与统计共用,避免两处上限漂移。 */
28
28
  const FULL_SCAN_LIMIT = 100000;
29
- /** 倒序读取梦境(新梦在前);损坏行跳过。 */
30
- export function readDreams(journalDir, limit) {
29
+ /**
30
+ * 倒序读取梦境并**报告**跳过了多少行。
31
+ *
32
+ * 两个健壮性要求(评审实测发现):
33
+ * - 文件开头的 UTF-8 BOM 必须剥离,否则第一行解析失败会让整份日记读空;
34
+ * - 坏行跳过要可计数,不能静默——调用方(dream_health / 只读路由)可以透出。
35
+ */
36
+ export function readDreamsDetailed(journalDir, limit) {
31
37
  const file = journalFile(journalDir);
32
38
  if (!existsSync(file))
33
- return [];
34
- const lines = readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '');
35
- const out = [];
39
+ return { dreams: [], skippedLines: 0 };
40
+ // 开头 BOM 不参与解析(Windows 记事本等工具会写入)。
41
+ const text = readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
42
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
43
+ const dreams = [];
44
+ let skippedLines = 0;
36
45
  for (const line of lines) {
37
46
  try {
38
47
  const rec = JSON.parse(line);
39
48
  if (typeof rec.reflection === 'string') {
40
- out.push({
49
+ dreams.push({
41
50
  id: String(rec.id ?? ''),
42
51
  at: String(rec.at ?? ''),
43
52
  reflection: rec.reflection,
@@ -45,10 +54,18 @@ export function readDreams(journalDir, limit) {
45
54
  mood: typeof rec.mood === 'string' ? rec.mood : '',
46
55
  });
47
56
  }
57
+ else
58
+ skippedLines += 1;
59
+ }
60
+ catch {
61
+ skippedLines += 1;
48
62
  }
49
- catch { /* 损坏行跳过 */ }
50
63
  }
51
- return out.reverse().slice(0, limit);
64
+ return { dreams: dreams.reverse().slice(0, limit), skippedLines };
65
+ }
66
+ /** 倒序读取梦境(新梦在前);损坏行跳过。 */
67
+ export function readDreams(journalDir, limit) {
68
+ return readDreamsDetailed(journalDir, limit).dreams;
52
69
  }
53
70
  /**
54
71
  * 统计梦境(基于全量日记)。
@@ -58,8 +75,10 @@ export function readDreams(journalDir, limit) {
58
75
  * 大小写,不再二次 flatMap(否则同一条教训的三个数值会来自不同的扫描)。
59
76
  */
60
77
  export function dreamStats(journalDir) {
61
- const dreams = readDreams(journalDir, FULL_SCAN_LIMIT);
62
- const moods = {};
78
+ const { dreams, skippedLines } = readDreamsDetailed(journalDir, FULL_SCAN_LIMIT);
79
+ // 无原型对象:心境是用户可控文本,'__proto__' / 'constructor' 这类键
80
+ // 在普通对象上会被原型吞掉或把计数变成字符串(评审实测)。
81
+ const moods = Object.create(null);
63
82
  const lessons = new Map();
64
83
  for (const dream of dreams) {
65
84
  const mood = dream.mood !== '' ? dream.mood : '平静';
@@ -78,7 +97,9 @@ export function dreamStats(journalDir) {
78
97
  const topLessons = [...lessons.values()]
79
98
  .sort((a, b) => b.count - a.count || b.lastAt.localeCompare(a.lastAt))
80
99
  .slice(0, 10);
81
- return { total: dreams.length, moods, topLessons };
100
+ // 对外给普通对象副本:内部用无原型对象防键污染,但消费方(deepStrictEqual / 原型判断)
101
+ // 期望的是普通对象。展开赋值走 CreateDataProperty,不会触发 __proto__ setter。
102
+ return { total: dreams.length, moods: { ...moods }, topLessons, skippedLines };
82
103
  }
83
104
  /** 关键词检索梦境(不区分大小写,命中 reflection/lessons;扫描全量,上限见 FULL_SCAN_LIMIT)。 */
84
105
  export function searchDreams(journalDir, query, limit) {