@shgroup/dsh-serenity-hooks 1.26.2 → 1.26.4

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/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.26.2",
3
+ "version": "1.26.4",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):真实 DSH 工具 cc_fs/session/acc_msm/eap/neat/cce/handyman/session_rebuild/skiff_admin + 拦截缝机械约束(safe-mode/路径守卫)+ 高级设定面板(双端口网关/账号管理)+ Skiff 认知子集角色(实验性)。适配 DSH 公开版(0.1.0-rc,deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
package/lib/index.js CHANGED
@@ -785,6 +785,17 @@ const CC_FS_WRITE_ACTIONS = /* @__PURE__ */ new Set([
785
785
  "touch",
786
786
  "append"
787
787
  ]);
788
+ /**
789
+ * 凭据文件硬名单(数据面守卫,v1.26.3,S142 用户:localstore.json 需要在 read 工具的黑名单里):
790
+ * **任何工具**(含 read/grep/glob/cc_fs 只读子命令)命中这些相对 CCC 根的路径 → deny。
791
+ *
792
+ * 与 safeMode.blacklist(写黑名单,v1.18.5 决策只拦写)语义独立:
793
+ * - 黑名单 = 用户可配置的写保护(REPOSITORIES/ 等只读参考源不误伤读)
794
+ * - 凭据硬名单 = **结构性数据面边界**(不依赖 safe-mode 开关,凭据文件任何时候不可读)——
795
+ * 对齐 CCE Bounded Space + wardn "structural guarantee, not policy":localstore.json
796
+ * 含 API keys/tokens/SSH 密码,即使 prompt 纪律失败也无值可吐(机械保证)。
797
+ */
798
+ const SENSITIVE_CREDENTIAL_FILES = /* @__PURE__ */ new Set(["localstore.json"]);
788
799
  /** 判定工具是否为写类:普通工具按名;cc_fs 复合工具按子命令 action */
789
800
  function isWriteTool(toolName, action) {
790
801
  if (!WRITE_TOOLS.has(toolName)) return false;
@@ -817,8 +828,12 @@ function decideGuard(input) {
817
828
  deny: `path escape blocked: "${pathArg}" escapes the CCC root`,
818
829
  kind: "deny"
819
830
  };
831
+ const rel = relative(root, abs).split("\\").join("/");
832
+ if (SENSITIVE_CREDENTIAL_FILES.has(rel)) return {
833
+ deny: `access blocked: "${pathArg}" is a sensitive credential file (${rel})`,
834
+ kind: "deny"
835
+ };
820
836
  if (isWriteTool(toolName, action)) {
821
- const rel = relative(root, abs).split("\\").join("/");
822
837
  if (rel === ".serenity-safe-on" || rel === ".serenity" || rel.startsWith(".serenity-safe-on/") || rel.startsWith(".serenity/")) return {
823
838
  deny: `CCC governance file "${rel}" is reserved for the user — agent must not write`,
824
839
  kind: "deny"
@@ -2392,7 +2407,7 @@ function listActiveHandymen(root) {
2392
2407
  }
2393
2408
  //#endregion
2394
2409
  //#region src/skiff-core.ts
2395
- const PLUGIN_SOURCE$3 = {
2410
+ const PLUGIN_SOURCE$4 = {
2396
2411
  kind: "plugin",
2397
2412
  plugin: "dsh-serenity-hooks"
2398
2413
  };
@@ -2491,7 +2506,7 @@ function waitIdle$1(ctx, agent) {
2491
2506
  });
2492
2507
  }
2493
2508
  /** 读会话最后一个 assistant/message 文本(handyman 同款) */
2494
- function lastAssistantText$1(agent) {
2509
+ function lastAssistantText$2(agent) {
2495
2510
  const events = agent.session.events;
2496
2511
  for (let i = events.length - 1; i >= 0; i--) {
2497
2512
  const e = events[i];
@@ -2560,10 +2575,10 @@ async function askSkiff(ctx, agent, question, eventsStart) {
2560
2575
  type: "text",
2561
2576
  text: question
2562
2577
  }],
2563
- source: PLUGIN_SOURCE$3
2578
+ source: PLUGIN_SOURCE$4
2564
2579
  }));
2565
2580
  await waitIdle$1(ctx, agent);
2566
- const answer = lastAssistantText$1(agent);
2581
+ const answer = lastAssistantText$2(agent);
2567
2582
  const trajectory = eventsToTrajectory(agent.session.events.slice(before));
2568
2583
  return {
2569
2584
  answer,
@@ -2973,7 +2988,7 @@ function waitIdle(ctx, agent) {
2973
2988
  });
2974
2989
  }
2975
2990
  /** 读取会话最后一个 assistant/message 文本 */
2976
- function lastAssistantText(agent) {
2991
+ function lastAssistantText$1(agent) {
2977
2992
  const events = agent.session.events;
2978
2993
  for (let i = events.length - 1; i >= 0; i--) {
2979
2994
  const e = events[i];
@@ -3071,7 +3086,7 @@ async function runHandymanJob(ctx, opts) {
3071
3086
  }
3072
3087
  }));
3073
3088
  await waitIdle(ctx, workerAgent);
3074
- lastResponse = lastAssistantText(workerAgent);
3089
+ lastResponse = lastAssistantText$1(workerAgent);
3075
3090
  } catch {
3076
3091
  restarts++;
3077
3092
  if (restarts > 100) {
@@ -6567,7 +6582,7 @@ function registerBootstrap(ctx) {
6567
6582
  }
6568
6583
  //#endregion
6569
6584
  //#region src/rebuild.ts
6570
- const PLUGIN_SOURCE$2 = {
6585
+ const PLUGIN_SOURCE$3 = {
6571
6586
  kind: "plugin",
6572
6587
  plugin: "dsh-serenity-hooks"
6573
6588
  };
@@ -6753,7 +6768,7 @@ function registerRebuildTurnHook(ctx) {
6753
6768
  type: "text",
6754
6769
  text: "[TRAJECTORY-REBUILD] The conversation has been cleared and rebuilt. Follow the anchor instructions above now: read the persistent trajectory (SESSION.md) and continue the work automatically from the last checkpoint."
6755
6770
  }],
6756
- source: PLUGIN_SOURCE$2
6771
+ source: PLUGIN_SOURCE$3
6757
6772
  }));
6758
6773
  console.log(`[serenity-hooks] session_rebuild executed with auto-continue (turn ${payload.turn ?? "?"} ended): ${id}`);
6759
6774
  }
@@ -7150,7 +7165,7 @@ function readContextPressure(ctx, session) {
7150
7165
  return null;
7151
7166
  }
7152
7167
  }
7153
- const PLUGIN_SOURCE$1 = {
7168
+ const PLUGIN_SOURCE$2 = {
7154
7169
  kind: "plugin",
7155
7170
  plugin: "dsh-serenity-hooks"
7156
7171
  };
@@ -7213,7 +7228,7 @@ function registerKeeper(ctx, opts = {}) {
7213
7228
  if (blocks.length === 0) return downstream;
7214
7229
  const reminderAll = createUserMessage({
7215
7230
  content: blocks,
7216
- source: PLUGIN_SOURCE$1
7231
+ source: PLUGIN_SOURCE$2
7217
7232
  });
7218
7233
  if (downstream.kind === "block") return {
7219
7234
  kind: "block",
@@ -8168,7 +8183,7 @@ function accIdentityText(root, configPaths = DEFAULT_SERENITY_CONFIG_PATHS, entr
8168
8183
  }
8169
8184
  return lines.join("\n");
8170
8185
  }
8171
- const PLUGIN_SOURCE = {
8186
+ const PLUGIN_SOURCE$1 = {
8172
8187
  kind: "plugin",
8173
8188
  plugin: "dsh-serenity-hooks"
8174
8189
  };
@@ -8185,7 +8200,7 @@ function accMessage(root, configPaths, entrySkillMaxChars) {
8185
8200
  }];
8186
8201
  return createUserMessage({
8187
8202
  content,
8188
- source: PLUGIN_SOURCE
8203
+ source: PLUGIN_SOURCE$1
8189
8204
  });
8190
8205
  }
8191
8206
  /** 每 agent 是否已注入过(进程内存态) */
@@ -9562,6 +9577,171 @@ function registerGateway(ctx) {
9562
9577
  });
9563
9578
  }
9564
9579
  //#endregion
9580
+ //#region src/output-guard.ts
9581
+ /**
9582
+ * output-guard.ts — 最终输出敏感词检测 + 打回重生成(v1.26.3,S142 用户需求)
9583
+ *
9584
+ * 认知容器对外提供认知结果时,**支撑宁静号自身的凭据与机制**不应透露给用户。
9585
+ * 用户拍板方案(讨论稿定稿):不做工具结果 redaction(那是模型工作内存),
9586
+ * 改为**卡最终输出**——`agent/turn-stopping`(serial)时取 turn 最后 assistant 文本,
9587
+ * 命中敏感词表 → `agent.steer()` 打回重生成,直到合规或达上限。
9588
+ *
9589
+ * 敏感词表(有限,ACC 立场,用户确认"这样比较全面了"):
9590
+ * ① 凭据词 — localstore.json credentials 条目名 + 值精确匹配(运行时读)
9591
+ * ② 机制词 — 内部结构静态词(插件名/配置路径/端口/内部实现词)
9592
+ * ③ MSM 词 — mech-registry.json 注册的工具名(用户补充:msm_list 里的工具名)
9593
+ * **不含** 公开概念(宁静号/Serenity/认知容器/EAP/CCE/Neat——正常交流词,误伤灾难)
9594
+ *
9595
+ * 机制依据(DSH 官方):agent/turn-stopping 注释 "a listener that objects steers and
9596
+ * the machine re-reads its inbox: fresh steering runs another step, none closes the
9597
+ * turn"——steer 打回是官方支持的"重生成"通道(v1.22.5 rebuild 自动继续同款先例)。
9598
+ */
9599
+ /** 插件/内部实现词(静态内置;命中即视为机制泄露) */
9600
+ const MECHANISM_WORDS = [
9601
+ "dsh-serenity-hooks",
9602
+ "serenity-hooks",
9603
+ "serenity-hooks.json",
9604
+ "mech-registry",
9605
+ "mech-registry.json",
9606
+ "localstore.json",
9607
+ ".opencode/serenity.json",
9608
+ "AGENT_SESSIONS",
9609
+ "agent/session-start",
9610
+ "agent/turn-stopping",
9611
+ "tools/pre-execute",
9612
+ "tools/post-execute",
9613
+ "session_rebuild",
9614
+ "Trajectory Steward",
9615
+ "first-anchor",
9616
+ "拦截缝",
9617
+ "装配层",
9618
+ "acp-core",
9619
+ "skiff-core",
9620
+ "skiff-registry",
9621
+ "skiff-role",
9622
+ "output-guard"
9623
+ ];
9624
+ /** 内部端口(宁静号服务面) */
9625
+ const MECHANISM_PORTS = [
9626
+ "3080",
9627
+ "3081",
9628
+ "3099",
9629
+ "3100"
9630
+ ];
9631
+ /**
9632
+ * 构建敏感词表(每次调用实时构建——词源文件小,开销可接受):
9633
+ * ① localstore.json credentials:条目名(UPPER_SNAKE)+ 值(精确匹配)
9634
+ * ② 静态机制词 + 端口
9635
+ * ③ mech-registry 注册的 MSM 工具名(loadMsmEntries 全注册表去重)
9636
+ * @param root CCC 根(localstore.json / mech-registry 所在)
9637
+ */
9638
+ function buildSensitiveTable(root) {
9639
+ const exact = /* @__PURE__ */ new Set();
9640
+ const substring = [];
9641
+ try {
9642
+ const p = join(root, "localstore.json");
9643
+ if (existsSync(p)) {
9644
+ const creds = JSON.parse(readFileSync(p, "utf-8").replace(/^\uFEFF/, ""))?.credentials;
9645
+ if (creds && typeof creds === "object") for (const [name, value] of Object.entries(creds)) {
9646
+ if (name) exact.add(name);
9647
+ if (typeof value === "string" && value !== "") exact.add(value);
9648
+ }
9649
+ }
9650
+ } catch {}
9651
+ for (const w of MECHANISM_WORDS) substring.push(w);
9652
+ for (const p of MECHANISM_PORTS) substring.push(p);
9653
+ try {
9654
+ for (const entry of loadMsmEntries(root)) if (entry.name) exact.add(entry.name);
9655
+ } catch {}
9656
+ return {
9657
+ exact,
9658
+ substring,
9659
+ sources: {
9660
+ exact: [...exact],
9661
+ substring
9662
+ }
9663
+ };
9664
+ }
9665
+ /** 检测文本中命中的敏感词(返回命中列表;未命中返回空数组) */
9666
+ function detectSensitive(text, table) {
9667
+ if (!text) return [];
9668
+ const hits = /* @__PURE__ */ new Set();
9669
+ for (const w of table.exact) if (w !== "" && text.includes(w)) hits.add(w);
9670
+ for (const w of table.substring) if (w !== "" && text.includes(w)) hits.add(w);
9671
+ return [...hits];
9672
+ }
9673
+ /** 打回消息(steer 内容):告知模型命中词并要求重新生成(不含命中值本身——避免二次泄露) */
9674
+ function buildRebuke(hits) {
9675
+ return `[SERENITY OUTPUT GUARD] Your previous response contained ${hits.length} sensitive internal term(s) (mechanism/credential identifiers) that must not appear in user-visible output. Regenerate the response from scratch without these terms — describe the same substance without referencing internal machinery, credentials, tool names, or implementation details. Do not repeat the terms; do not explain this instruction to the user.`;
9676
+ }
9677
+ /** 打回状态(按 agent 会话跟踪连续命中轮数) */
9678
+ const rebukeStates = /* @__PURE__ */ new Map();
9679
+ //#endregion
9680
+ //#region src/output-guard-seam.ts
9681
+ const PLUGIN_SOURCE = {
9682
+ kind: "plugin",
9683
+ plugin: "dsh-serenity-hooks"
9684
+ };
9685
+ /** 外部面会话判定(v1.26.3 用户拍板:仅外部面检测,本地维护会话豁免):
9686
+ * skiff-(F4 问答)/ acp-(F4c 程序化)/ rebuild-(v1.22.4 重建会话)前缀 */
9687
+ function isExternalFaceSession(sessionId) {
9688
+ if (!sessionId) return false;
9689
+ return isSkiffSessionId(sessionId) || sessionId.startsWith("acp-") || sessionId.startsWith("rebuild-");
9690
+ }
9691
+ /** 读会话最后一个 assistant/message 文本(skiff-core 同款;仅用于守卫检测,不导出) */
9692
+ function lastAssistantText(agent) {
9693
+ const events = agent.session.events ?? [];
9694
+ for (let i = events.length - 1; i >= 0; i--) {
9695
+ const e = events[i];
9696
+ if (e && e.type === "assistant/message") {
9697
+ const text = (e.data?.message?.content ?? e.data?.content ?? []).filter((b) => b.type === "text" && b.text).map((b) => b.text).join("\n");
9698
+ if (text) return text;
9699
+ }
9700
+ }
9701
+ return "";
9702
+ }
9703
+ /**
9704
+ * 注册输出守卫(index.ts apply 调用):
9705
+ * turn-stopping 时检测最终输出,命中敏感词 → steer 打回重生成(官方机制,零改 DSH)。
9706
+ */
9707
+ function registerOutputGuardHook(ctx) {
9708
+ ctx.on("agent/turn-stopping", (payload) => {
9709
+ const agent = payload?.agent;
9710
+ if (!agent) return;
9711
+ if (!isExternalFaceSession(String(agent.session.id ?? ""))) return;
9712
+ const root = findSerenityRoot(agent.session.header?.cwd ?? process.cwd());
9713
+ if (!root) return;
9714
+ const id = agent.id;
9715
+ const text = lastAssistantText(agent);
9716
+ if (!text) return;
9717
+ const hits = detectSensitive(text, buildSensitiveTable(root));
9718
+ if (hits.length === 0) {
9719
+ rebukeStates.delete(id);
9720
+ return;
9721
+ }
9722
+ const st = rebukeStates.get(id) ?? { consecutive: 0 };
9723
+ st.consecutive += 1;
9724
+ rebukeStates.set(id, st);
9725
+ if (st.consecutive > 3) {
9726
+ rebukeStates.delete(id);
9727
+ console.warn(`[serenity-hooks] output-guard: giving up after 3 rebukes (agent=${id}, turn=${payload.turn ?? "?"}) — response kept for manual review`);
9728
+ return;
9729
+ }
9730
+ console.log(`[serenity-hooks] output-guard: rebuke ${st.consecutive}/3 (agent=${id}, turn=${payload.turn ?? "?"}, hits=${hits.length})`);
9731
+ try {
9732
+ agent.steer(createUserMessage({
9733
+ content: [{
9734
+ type: "text",
9735
+ text: buildRebuke(hits)
9736
+ }],
9737
+ source: PLUGIN_SOURCE
9738
+ }));
9739
+ } catch (error) {
9740
+ console.warn(`[serenity-hooks] output-guard steer failed: ${String(error?.message ?? error)}`);
9741
+ }
9742
+ });
9743
+ }
9744
+ //#endregion
9565
9745
  //#region node_modules/.pnpm/marked@18.0.11/node_modules/marked/lib/marked.esm.js
9566
9746
  /**
9567
9747
  * marked v18.0.11 - a markdown parser
@@ -11483,9 +11663,11 @@ function escapeHtml(s) {
11483
11663
  * ① 提取 `<think>…</think>` 块(占位符 \u0000T<idx>\u0000)
11484
11664
  * ② 正文与 think 内容**先 escapeHtml 再 marked.parse**(GFM + breaks)——markdown 语法不受
11485
11665
  * 转义影响,原始 HTML 注入被消除(安全);代码块内 `<` 显示为实体(可接受)
11486
- * ③ think 占位符还原为 `<details class="think">` 折叠卡(🧠 思考过程,默认收起)
11666
+ * ③ think 占位符:默认还原为 `<details class="think">` 折叠卡(🧠 思考过程,默认收起);
11667
+ * **hideThink=true(v1.26.4,public 口)→ 直接移除**(思考过程对普通用户不展示)
11668
+ * @param hideThink 为 true 时 `<think>` 内容完全不渲染(public 问答页体验)
11487
11669
  */
11488
- function renderSkiffMarkdown(raw) {
11670
+ function renderSkiffMarkdown(raw, hideThink = false) {
11489
11671
  const thinks = [];
11490
11672
  const body = raw.replace(/<think>([\s\S]*?)<\/think>/gi, (_m, inner) => {
11491
11673
  const idx = thinks.length;
@@ -11497,6 +11679,7 @@ function renderSkiffMarkdown(raw) {
11497
11679
  gfm: true
11498
11680
  });
11499
11681
  return (typeof parsed === "string" ? parsed : "").replace(/\u0000T(\d+)\u0000/g, (_m, idx) => {
11682
+ if (hideThink) return "";
11500
11683
  const inner = thinks[Number(idx)] ?? "";
11501
11684
  const innerHtml = inner === "" ? "" : f.parse(escapeHtml(inner), {
11502
11685
  breaks: true,
@@ -12013,7 +12196,7 @@ async function handleAskParsed(ctx, ccc, body, res) {
12013
12196
  const result = await askSkiff(ctx, agent, question, 0);
12014
12197
  sendJson(res, 200, {
12015
12198
  answer: result.answer,
12016
- answer_html: renderSkiffMarkdown(result.answer),
12199
+ answer_html: renderSkiffMarkdown(result.answer, true),
12017
12200
  sessionId: result.sessionId,
12018
12201
  continued,
12019
12202
  trajectory: result.trajectory
@@ -12048,39 +12231,77 @@ function publicAskContainerClosedHtml(name) {
12048
12231
  <p style="font-size:13px"><a href="/" style="color:#0ba875">返回容器列表</a></p>
12049
12232
  </body></html>`;
12050
12233
  }
12051
- /** 页面基础样式(列表页 + 单容器页共用) */
12234
+ /** 页面基础样式(列表页 + 单容器页共用;v1.26.4 聊天 UI) */
12052
12235
  const ASK_CSS = `
12053
- :root { color-scheme: light dark; }
12054
- body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 24px; background: #f6f7f9; color: #1f2328; }
12055
- @media (prefers-color-scheme: dark) { body { background: #1a1b1e; color: #e6e6e6; } }
12236
+ :root { color-scheme: light dark; --sp-green: #0ba875; --sp-green-dim: #059669; }
12237
+ * { box-sizing: border-box; }
12238
+ html, body { height: 100%; }
12239
+ body { font-family: system-ui, -apple-system, sans-serif; margin: 0; background: #f6f7f9; color: #1f2328; }
12240
+ @media (prefers-color-scheme: dark) { body { background: #16181c; color: #e6e6e6; } }
12056
12241
  main { max-width: 720px; margin: 0 auto; }
12057
12242
  h1 { font-size: 18px; margin: 0 0 4px; }
12058
12243
  .sub { opacity: .65; font-size: 13px; margin-bottom: 16px; }
12059
- label { font-size: 13px; font-weight: 600; display: block; margin: 12px 0 4px; }
12060
- input, select, textarea { width: 100%; box-sizing: border-box; padding: 8px 10px; border-radius: 8px; border: 1px solid #d0d7de; background: #fff; color: inherit; font-size: 14px; }
12061
- @media (prefers-color-scheme: dark) { input, select, textarea { background: #26282c; border-color: #3a3d42; } }
12062
- textarea { min-height: 72px; resize: vertical; }
12063
- button { margin-top: 12px; padding: 9px 18px; border-radius: 8px; border: 0; background: #0ba875; color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; }
12064
- button:disabled { opacity: .55; cursor: wait; }
12065
12244
  .card { background: #fff; border: 1px solid #d0d7de; border-radius: 10px; padding: 14px 16px; margin-bottom: 10px; display: block; text-decoration: none; color: inherit; }
12066
12245
  @media (prefers-color-scheme: dark) { .card { background: #26282c; border-color: #3a3d42; } }
12067
- .card:hover { border-color: #0ba875; }
12246
+ .card:hover { border-color: var(--sp-green); }
12068
12247
  .card .cname { font-weight: 600; font-size: 15px; }
12069
12248
  .card .cmeta { opacity: .6; font-size: 12px; margin-top: 2px; }
12070
- #answer { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 12px; margin-top: 16px; font-size: 14px; line-height: 1.6; min-height: 48px; word-break: break-word; }
12071
- @media (prefers-color-scheme: dark) { #answer { background: #26282c; border-color: #3a3d42; } }
12072
- #answer p { margin: 6px 0; }
12073
- #answer h1, #answer h2, #answer h3 { margin: 12px 0 6px; font-size: 15px; }
12074
- #answer code { background: rgba(127,127,127,.14); border-radius: 4px; padding: 1px 5px; font-size: 13px; font-family: ui-monospace, monospace; }
12075
- #answer pre { background: rgba(127,127,127,.1); border-radius: 8px; padding: 10px 12px; overflow-x: auto; }
12076
- #answer ul, #answer ol { margin: 6px 0; padding-left: 22px; }
12077
- .err { color: #cf222e; white-space: pre-wrap; }
12078
- .muted { opacity: .6; font-size: 13px; }
12079
- details.think { margin: 8px 0; border: 1px solid rgba(210,153,34,.4); border-radius: 8px; background: rgba(210,153,34,.06); }
12080
- details.think summary { cursor: pointer; padding: 6px 10px; font-size: 12px; color: #d29922; font-weight: 600; }
12081
12249
  .empty { text-align: center; opacity: .7; padding: 24px 0; }
12082
- .back { display: inline-block; margin-bottom: 12px; font-size: 13px; color: #0ba875; text-decoration: none; }
12083
- .ccname { font-weight: 600; color: #0ba875; }
12250
+ .ccname { font-weight: 600; color: var(--sp-green); }
12251
+
12252
+ /* ── 聊天布局(v1.26.4 体验升级:参考 ChatGPT/Claude 会话形态)── */
12253
+ .chat { display: flex; flex-direction: column; height: 100vh; max-width: 720px; margin: 0 auto; }
12254
+ .chatHead { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-bottom: 1px solid rgba(127,127,127,.18); background: rgba(127,127,127,.04); position: sticky; top: 0; z-index: 5; }
12255
+ .chatHead .back { color: var(--sp-green); text-decoration: none; font-size: 13px; white-space: nowrap; }
12256
+ .chatHead .title { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
12257
+ .chatHead .spacer { flex: 1; }
12258
+ .chatHead select { border: 1px solid rgba(127,127,127,.25); border-radius: 8px; background: transparent; color: inherit; padding: 4px 8px; font-size: 12px; }
12259
+ .chatHead .newBtn { border: 1px solid rgba(127,127,127,.25); border-radius: 8px; background: transparent; color: inherit; padding: 4px 10px; font-size: 12px; cursor: pointer; white-space: nowrap; }
12260
+ .chatHead .newBtn:hover { border-color: var(--sp-green); color: var(--sp-green); }
12261
+ /* 消息流 */
12262
+ .msgList { flex: 1; overflow-y: auto; padding: 18px 16px; display: flex; flex-direction: column; gap: 16px; }
12263
+ .msg { display: flex; flex-direction: column; max-width: 88%; }
12264
+ .msg.user { align-self: flex-end; align-items: flex-end; }
12265
+ .msg.assistant { align-self: flex-start; align-items: flex-start; }
12266
+ .msg .who { font-size: 11px; opacity: .55; margin-bottom: 4px; padding: 0 4px; }
12267
+ .msg .bubble { padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.65; word-break: break-word; white-space: normal; }
12268
+ .msg.user .bubble { background: var(--sp-green); color: #fff; border-bottom-right-radius: 4px; }
12269
+ .msg.assistant .bubble { background: #fff; border: 1px solid #d0d7de; border-bottom-left-radius: 4px; }
12270
+ @media (prefers-color-scheme: dark) { .msg.assistant .bubble { background: #26282c; border-color: #3a3d42; } }
12271
+ .bubble p { margin: 6px 0; }
12272
+ .bubble p:first-child { margin-top: 0; }
12273
+ .bubble p:last-child { margin-bottom: 0; }
12274
+ .bubble h1, .bubble h2, .bubble h3, .bubble h4 { margin: 12px 0 6px; font-size: 15px; line-height: 1.4; }
12275
+ .bubble ul, .bubble ol { margin: 6px 0; padding-left: 22px; }
12276
+ .bubble code { background: rgba(127,127,127,.14); border-radius: 4px; padding: 1px 5px; font-size: 13px; font-family: ui-monospace, monospace; }
12277
+ .bubble pre { background: rgba(127,127,127,.1); border-radius: 8px; padding: 10px 12px; overflow-x: auto; margin: 8px 0; }
12278
+ .bubble pre code { background: none; padding: 0; }
12279
+ .bubble blockquote { margin: 6px 0; padding: 2px 12px; border-left: 3px solid rgba(127,127,127,.3); opacity: .85; }
12280
+ .bubble a { color: var(--sp-green); }
12281
+ .bubble table { border-collapse: collapse; margin: 8px 0; }
12282
+ .bubble th, .bubble td { border: 1px solid rgba(127,127,127,.25); padding: 4px 10px; font-size: 13px; }
12283
+ .bubble img { max-width: 100%; border-radius: 8px; }
12284
+ .bubble hr { border: 0; border-top: 1px solid rgba(127,127,127,.2); margin: 10px 0; }
12285
+ .typing { display: inline-flex; align-items: center; gap: 4px; padding: 4px 2px; }
12286
+ .typing i { width: 6px; height: 6px; border-radius: 50%; background: rgba(127,127,127,.5); animation: blink 1.2s infinite; }
12287
+ .typing i:nth-child(2) { animation-delay: .2s; }
12288
+ .typing i:nth-child(3) { animation-delay: .4s; }
12289
+ @keyframes blink { 0%, 80%, 100% { opacity: .3 } 40% { opacity: 1 } }
12290
+ .err { color: #cf222e; white-space: pre-wrap; font-size: 13px; }
12291
+ .msg .err { padding: 0 4px; }
12292
+ /* 输入区 */
12293
+ .chatInput { border-top: 1px solid rgba(127,127,127,.18); padding: 10px 16px calc(12px + env(safe-area-inset-bottom)); background: rgba(127,127,127,.04); }
12294
+ .chatInput .keyRow { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
12295
+ .chatInput .keyRow label { font-size: 12px; opacity: .7; white-space: nowrap; }
12296
+ .chatInput .keyRow input { flex: 1; border: 1px solid rgba(127,127,127,.25); border-radius: 8px; background: transparent; color: inherit; padding: 6px 10px; font-size: 13px; }
12297
+ .inputRow { display: flex; align-items: flex-end; gap: 8px; }
12298
+ .inputRow textarea { flex: 1; border: 1px solid rgba(127,127,127,.25); border-radius: 12px; background: #fff; color: inherit; padding: 10px 12px; font-size: 14px; resize: none; min-height: 44px; max-height: 140px; line-height: 1.5; }
12299
+ @media (prefers-color-scheme: dark) { .inputRow textarea { background: #26282c; border-color: #3a3d42; } }
12300
+ .inputRow textarea:focus { outline: none; border-color: var(--sp-green); }
12301
+ .inputRow .sendBtn { border: 0; border-radius: 12px; background: var(--sp-green); color: #fff; padding: 10px 18px; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; }
12302
+ .inputRow .sendBtn:disabled { opacity: .5; cursor: wait; }
12303
+ .inputRow .sendBtn:hover:not(:disabled) { background: var(--sp-green-dim); }
12304
+ .hint { text-align: center; font-size: 12px; opacity: .55; padding: 20px 0; }
12084
12305
  `;
12085
12306
  /** 容器列表页(v1.26.2):只列已开放容器,点击进入 /c/<name> */
12086
12307
  function publicAskListPage(cccs) {
@@ -12106,7 +12327,7 @@ function publicAskListPage(cccs) {
12106
12327
  </body>
12107
12328
  </html>`;
12108
12329
  }
12109
- /** 单容器问答页(v1.26.2):URL 锁定容器,key + 角色;key 自动存 localStorage */
12330
+ /** 单容器问答页(v1.26.2→v1.26.4):URL 锁定容器;聊天 UI——消息流 + 连续对话 + key 自动记忆 */
12110
12331
  function publicAskContainerPage(ccc) {
12111
12332
  const data = JSON.stringify({
12112
12333
  name: ccc.name,
@@ -12118,23 +12339,32 @@ function publicAskContainerPage(ccc) {
12118
12339
  <head>
12119
12340
  <meta charset="utf-8">
12120
12341
  <meta name="viewport" content="width=device-width, initial-scale=1">
12121
- <title>Serenity Public Ask · ${ccc.name}</title>
12342
+ <title>Serenity · ${ccc.name}</title>
12122
12343
  <style>${ASK_CSS}</style>
12123
12344
  </head>
12124
12345
  <body>
12125
- <main>
12126
- <a class="back" href="/">← 容器列表</a>
12127
- <h1>Serenity Public Ask</h1>
12128
- <div class="sub">认知容器:<span class="ccname">${ccc.name}</span></div>
12129
- <label for="key">访问 Key</label>
12130
- <input id="key" type="password" placeholder="请输入访问 key" autocomplete="off">
12131
- <label for="role">问答角色</label>
12132
- <select id="role"></select>
12133
- <label for="q">问题</label>
12134
- <textarea id="q" placeholder="向该认知容器提问…"></textarea>
12135
- <button id="ask">提问</button>
12136
- <div id="answer" class="muted">等待提问…</div>
12137
- </main>
12346
+ <div class="chat">
12347
+ <div class="chatHead">
12348
+ <a class="back" href="/">← 容器</a>
12349
+ <span class="title">Serenity · <span class="ccname">${ccc.name}</span></span>
12350
+ <span class="spacer"></span>
12351
+ <select id="role" title="问答角色"></select>
12352
+ <button id="newChat" class="newBtn" title="清空当前对话(不存储,刷新即失)">新对话</button>
12353
+ </div>
12354
+
12355
+ <div id="msgs" class="msgList"></div>
12356
+
12357
+ <div class="chatInput">
12358
+ <div class="keyRow" id="keyRow">
12359
+ <label for="key">访问 Key</label>
12360
+ <input id="key" type="password" placeholder="请输入访问 key(自动记忆)" autocomplete="off">
12361
+ </div>
12362
+ <div class="inputRow">
12363
+ <textarea id="q" placeholder="向 ${ccc.name} 提问…(Enter 发送,Shift+Enter 换行)" rows="1"></textarea>
12364
+ <button id="ask" class="sendBtn">发送</button>
12365
+ </div>
12366
+ </div>
12367
+ </div>
12138
12368
  <script id="ask-data" type="application/json">${data}<\/script>
12139
12369
  <script>
12140
12370
  const CCC = JSON.parse(document.getElementById('ask-data').textContent)
@@ -12142,37 +12372,109 @@ const KEY_STORE = 'serenity-public-ask-key'
12142
12372
  const keyInput = document.getElementById('key')
12143
12373
  const roleSel = document.getElementById('role')
12144
12374
  const btn = document.getElementById('ask')
12145
- const answer = document.getElementById('answer')
12375
+ const msgs = document.getElementById('msgs')
12376
+ const qInput = document.getElementById('q')
12377
+ const keyRow = document.getElementById('keyRow')
12146
12378
  let sessionId = ''
12147
- // 角色下拉(v1.26.2 用户:3100 应能选择角色)
12379
+ let busy = false
12380
+
12381
+ // 角色下拉
12148
12382
  roleSel.innerHTML = (CCC.roles || []).map((r) => '<option value="' + r + '">' + r + '</option>').join('') || '<option value="">(无角色)</option>'
12149
- // key 自动从 localStorage 恢复(v1.26.2:输入一次后浏览器记住,无需每次粘贴)
12150
- try { const saved = localStorage.getItem(KEY_STORE); if (saved) keyInput.value = saved } catch {}
12151
- btn.addEventListener('click', async () => {
12152
- const q = document.getElementById('q').value.trim()
12383
+ // key 自动从 localStorage 恢复
12384
+ try { const saved = localStorage.getItem(KEY_STORE); if (saved) { keyInput.value = saved; keyRow.style.display = 'none' } } catch {}
12385
+
12386
+ /** 追加一条消息气泡(role: user|assistant) */
12387
+ function addMsg(role, htmlOrText, isHtml) {
12388
+ const m = document.createElement('div')
12389
+ m.className = 'msg ' + role
12390
+ const who = document.createElement('div')
12391
+ who.className = 'who'
12392
+ who.textContent = role === 'user' ? '你' : CCC.name
12393
+ const bubble = document.createElement('div')
12394
+ bubble.className = 'bubble'
12395
+ if (isHtml) bubble.innerHTML = htmlOrText
12396
+ else bubble.textContent = htmlOrText
12397
+ m.appendChild(who)
12398
+ m.appendChild(bubble)
12399
+ msgs.appendChild(m)
12400
+ msgs.scrollTop = msgs.scrollHeight
12401
+ return bubble
12402
+ }
12403
+
12404
+ /** 打字指示器(等待回答时显示) */
12405
+ function addTyping() {
12406
+ const m = document.createElement('div')
12407
+ m.className = 'msg assistant'
12408
+ const who = document.createElement('div')
12409
+ who.className = 'who'
12410
+ who.textContent = CCC.name
12411
+ const bubble = document.createElement('div')
12412
+ bubble.className = 'bubble'
12413
+ bubble.innerHTML = '<span class="typing"><i></i><i></i><i></i></span>'
12414
+ m.appendChild(who)
12415
+ m.appendChild(bubble)
12416
+ msgs.appendChild(m)
12417
+ msgs.scrollTop = msgs.scrollHeight
12418
+ return bubble
12419
+ }
12420
+
12421
+ async function ask() {
12422
+ const q = qInput.value.trim()
12153
12423
  const key = keyInput.value.trim()
12154
12424
  const role = roleSel.value || undefined
12155
- if (!q) { answer.className = 'err'; answer.textContent = '请输入问题'; return }
12156
- if (!key) { answer.className = 'err'; answer.textContent = '请输入访问 key(没有 key 无法使用)'; return }
12157
- // 输入即记住(保存到 localStorage;下次自动填充)
12158
- try { localStorage.setItem(KEY_STORE, key) } catch {}
12425
+ if (!q) return
12426
+ if (!key) { keyInput.focus(); return }
12427
+ if (busy) return
12428
+ // 保存 key(下次自动记忆 + 隐藏 key 行)
12429
+ try { localStorage.setItem(KEY_STORE, key); keyRow.style.display = 'none' } catch {}
12430
+ // 追加用户消息 + 打字指示
12431
+ addMsg('user', q, false)
12432
+ qInput.value = ''
12433
+ autoGrow()
12434
+ const typing = addTyping()
12435
+ busy = true
12159
12436
  btn.disabled = true
12160
- answer.className = 'muted'
12161
- answer.textContent = '运行中…'
12162
12437
  try {
12163
12438
  const res = await fetch('/c/' + encodeURIComponent(CCC.name) + '/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, role, question: q, sessionId: sessionId || undefined }) })
12164
12439
  const data = await res.json()
12165
- if (!res.ok) { sessionId = ''; throw new Error(data.error || ('HTTP ' + res.status)) }
12166
- answer.className = ''
12167
- answer.innerHTML = data.answer_html || data.answer || '(空回答)'
12440
+ typing.remove() // 移除打字指示,替换为真实回答
12441
+ if (!res.ok) {
12442
+ sessionId = ''
12443
+ addMsg('assistant', '⚠️ ' + (data.error || ('HTTP ' + res.status)), true)
12444
+ return
12445
+ }
12446
+ addMsg('assistant', data.answer_html || data.answer || '(空回答)', true)
12168
12447
  sessionId = data.sessionId || ''
12169
12448
  } catch (err) {
12170
- answer.className = 'err'
12171
- answer.textContent = String(err.message || err)
12449
+ typing.remove()
12450
+ addMsg('assistant', '⚠️ ' + String(err.message || err), true)
12172
12451
  } finally {
12452
+ busy = false
12173
12453
  btn.disabled = false
12454
+ qInput.focus()
12174
12455
  }
12456
+ }
12457
+
12458
+ function autoGrow() {
12459
+ qInput.style.height = 'auto'
12460
+ qInput.style.height = Math.min(qInput.scrollHeight, 140) + 'px'
12461
+ }
12462
+
12463
+ btn.addEventListener('click', () => void ask())
12464
+ qInput.addEventListener('keydown', (e) => {
12465
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void ask() }
12175
12466
  })
12467
+ qInput.addEventListener('input', autoGrow)
12468
+ // 新对话:重置 sessionId + 清空消息流(前端内存,不存储——刷新即失)
12469
+ document.getElementById('newChat').addEventListener('click', () => {
12470
+ sessionId = ''
12471
+ msgs.innerHTML = ''
12472
+ qInput.focus()
12473
+ })
12474
+
12475
+ // 初始欢迎消息(外层模板求值注入容器名;textContent 渲染防注入)
12476
+ addMsg('assistant', '你好,我是「${ccc.name}」的认知助手。有什么可以帮你的?(本对话仅当前页面有效,刷新即失)', false)
12477
+ qInput.focus()
12176
12478
  <\/script>
12177
12479
  </body>
12178
12480
  </html>`;
@@ -12252,6 +12554,7 @@ function apply(ctx, config) {
12252
12554
  registerSettingsSection(ctx, config);
12253
12555
  registerGateway(ctx);
12254
12556
  registerRebuildTurnHook(ctx);
12557
+ registerOutputGuardHook(ctx);
12255
12558
  if (config.env) registerEnv(ctx);
12256
12559
  if (config.opencodeSkills) registerOpencodeSkills(ctx);
12257
12560
  registerBootstrap(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.26.2",
3
+ "version": "1.26.4",
4
4
  "description": "宁静号 ACC harness — Native Cordis 插件(DeepSeek Harness 运行时)。真实 DSH 工具注册(cc_fs/session/acc_msm 等 9 工具)+ 拦截缝机械约束(safe-mode/路径守卫/会话落盘)+ 系统提示词注入(ACC/CCE/Constraints/SKILL/Session 五块)。适配 DSH 公开版(deepseek-ai/deepseek-harness 0.1.0-rc)。",
5
5
  "license": "MIT",
6
6
  "repository": {