dsh-decision-layer 0.6.0 → 0.6.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/README.en.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  `dsh-decision-layer` adds structured, explicitly scoped decisions to the DeepSeek Harness (DSH) agent loop, backed by a pluggable adjudication backend. Dangerous-call gating, output self-check, tool narrowing, and loop prevention hook into the agent loop as automatic interventions.
6
6
 
7
- > **0.6.0 is a preview release.** The plugin works, but how much real value the adjudication interventions add is still being explored — every threshold is an unvalidated heuristic default. Treat it as something to observe, not a proven guarantee of impact. See [ROADMAP.md](<ROADMAP.md>) for scope and progress.
7
+ > **0.6.1 is a preview release.** The plugin works, but how much real value the adjudication interventions add is still being explored — every threshold is an unvalidated heuristic default. Treat it as something to observe, not a proven guarantee of impact. See [ROADMAP.md](<ROADMAP.md>) for scope and progress.
8
8
 
9
9
  ## Installation
10
10
 
@@ -50,4 +50,4 @@ Restart `dsh web` and refresh after installing or updating: the host and client
50
50
 
51
51
  ## Safety boundary and development
52
52
 
53
- Automatic intervention only subtracts: the gate only denies, narrowing only removes tools, and neither grants new permissions. The gate defers to native DSH approval on backend failure rather than allowing; self-check, narrowing, and loop-guard errors skip intervention. The redacted summary sent to the backend carries only tool name and command/path with secret redaction — never file content. Node.js `>=20.11`; `npm test` and `npm run build:client` run local checks. Rebuild and commit `lib/client.js` with any client change. 0.6.0 is a preview: the interventions work, but their real value and thresholds are still being explored — feedback on misjudgments and feel observed in the decision log is welcome.
53
+ Automatic intervention only subtracts: the gate only denies, narrowing only removes tools, and neither grants new permissions. The gate defers to native DSH approval on backend failure rather than allowing; self-check, narrowing, and loop-guard errors skip intervention. The redacted summary sent to the backend carries only tool name and command/path with secret redaction — never file content. Node.js `>=20.11`; `npm test` and `npm run build:client` run local checks. Rebuild and commit `lib/client.js` with any client change. 0.6.1 is a preview: the interventions work, but their real value and thresholds are still being explored — feedback on misjudgments and feel observed in the decision log is welcome.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  DeepSeek Harness(DSH)的结构化裁决插件。它把有明确判断标准的问题交给同协议的裁决后端,供 agent 在需要时使用,并在 agent loop 上接入危险门控、产出自检、工具收窄和防循环等自动介入。
6
6
 
7
- > **0.6.0 是体验版。** 插件功能已可用,但裁决介入能带来多少实际价值仍在探索中——各决策点的阈值都是未经验证的启发式默认值,建议先以观察心态体验,别当成经过验证的效果保证。功能与后续进度以 [ROADMAP.md](<ROADMAP.md>) 为准。
7
+ > **0.6.1 是体验版。** 插件功能已可用,但裁决介入能带来多少实际价值仍在探索中——各决策点的阈值都是未经验证的启发式默认值,建议先以观察心态体验,别当成经过验证的效果保证。功能与后续进度以 [ROADMAP.md](<ROADMAP.md>) 为准。
8
8
 
9
9
  ## 安装
10
10
 
@@ -54,4 +54,4 @@ dsh plugin --profile web add file:/绝对路径/dsh-decision-layer
54
54
 
55
55
  ## 开发
56
56
 
57
- 服务端使用 Node.js `>=20.11`(`.mjs`,无需构建);客户端用 TypeScript + esbuild 构建。`npm test` 跑本地测试,`npm run build:client` 生成 `lib/client.js`。改动客户端后需重建并连同 `lib/client.js` 一起提交,否则 GitHub 安装拿到的是旧 bundle。0.6.0 为体验版:介入能力已可用,但实际价值与阈值仍在探索,欢迎反馈决策日志观察到的误判与体感。
57
+ 服务端使用 Node.js `>=20.11`(`.mjs`,无需构建);客户端用 TypeScript + esbuild 构建。`npm test` 跑本地测试,`npm run build:client` 生成 `lib/client.js`。改动客户端后需重建并连同 `lib/client.js` 一起提交,否则 GitHub 安装拿到的是旧 bundle。0.6.1 为体验版:介入能力已可用,但实际价值与阈值仍在探索,欢迎反馈决策日志观察到的误判与体感。
package/lib/client.js CHANGED
@@ -183,7 +183,10 @@ var dictionaries = {
183
183
  analysisAccuracy: "\u5224\u65AD\u51C6\u786E\u5EA6",
184
184
  analysisAccuracyEmpty: "\u5C1A\u672A\u6807\u6CE8\u4EFB\u4F55\u51B3\u7B56\u3002\u5728\u4E0B\u65B9\u51B3\u7B56\u5217\u8868\u9010\u6761\u6807\u6CE8\u300C\u5224\u5BF9 / \u5224\u9519 / \u8BF4\u4E0D\u51C6\u300D\u540E\uFF0C\u8FD9\u91CC\u7ED9\u51FA\u6C47\u603B\u3002",
185
185
  analysisLogNote: "\u6807\u6CE8\u5728\u6B64\u4E0E\u4ECB\u5165\u9762\u677F\u540C\u6E90\uFF1A\u4EFB\u4E00\u5904\u6807\u6CE8\u90FD\u5199\u5165\u540C\u4E00\u4EFD\u65E5\u5FD7\u3001\u6309\u51B3\u7B56 id \u5173\u8054\u3001\u53D6\u6700\u65B0\u3002",
186
- analysisLogTruncated: "\u672C\u4F1A\u8BDD\u6682\u65E0\u53EF\u5C55\u793A\u7684\u51B3\u7B56\u8BB0\u5F55\uFF08\u5185\u5B58\u65E5\u5FD7\u53EF\u80FD\u5DF2\u6EDA\u52A8\u6E05\u7406\uFF09\u3002"
186
+ analysisLogTruncated: "\u672C\u4F1A\u8BDD\u6682\u65E0\u53EF\u5C55\u793A\u7684\u51B3\u7B56\u8BB0\u5F55\u3002",
187
+ pagerPrev: "\u4E0A\u4E00\u9875",
188
+ pagerNext: "\u4E0B\u4E00\u9875",
189
+ pagerStatus: "\u7B2C {page}/{pages} \u9875 \xB7 \u5171 {total} \u6761"
187
190
  },
188
191
  en: {
189
192
  summary: "A danger-call gate, output self-check, and loop guard driven by a pluggable adjudication model.",
@@ -335,7 +338,10 @@ var dictionaries = {
335
338
  analysisAccuracy: "Decision accuracy",
336
339
  analysisAccuracyEmpty: "No decisions rated yet. Rate rows below as correct / wrong / unsure and a summary appears here.",
337
340
  analysisLogNote: "Ratings here share one source with the intervention panel: a rating on either surface writes to the same log, keyed by decision id, latest wins.",
338
- analysisLogTruncated: "No decision rows to show for this session (the in-memory log may have rolled over)."
341
+ analysisLogTruncated: "No decision rows to show for this session.",
342
+ pagerPrev: "Prev",
343
+ pagerNext: "Next",
344
+ pagerStatus: "Page {page}/{pages} \xB7 {total} total"
339
345
  }
340
346
  };
341
347
 
@@ -931,9 +937,11 @@ function SessionPanel({ sessionId, t }) {
931
937
  // src/client/analysis-view.tsx
932
938
  var import_react3 = require("react");
933
939
  var import_jsx_runtime4 = require("react/jsx-runtime");
940
+ var LOG_PAGE_SIZE = 100;
934
941
  function AnalysisView({ sessionId, t }) {
935
942
  const [analysis, setAnalysis] = (0, import_react3.useState)(null);
936
- const [log, setLog] = (0, import_react3.useState)([]);
943
+ const [rows, setRows] = (0, import_react3.useState)(null);
944
+ const [logPage, setLogPage] = (0, import_react3.useState)(1);
937
945
  const [loaded, setLoaded] = (0, import_react3.useState)(false);
938
946
  const [message, setMessage] = (0, import_react3.useState)("");
939
947
  const { ratings, seed, annotate } = useAnnotations(sessionId, () => setMessage(t("annotateFailed")));
@@ -941,23 +949,31 @@ function AnalysisView({ sessionId, t }) {
941
949
  let alive = true;
942
950
  setMessage("");
943
951
  setLoaded(false);
944
- void Promise.allSettled([
945
- request(`logs?sessionId=${encodeURIComponent(sessionId)}`).then((value) => {
946
- if (alive) {
947
- setAnalysis(value);
948
- seed(value);
949
- }
950
- }),
951
- request(`log?sessionId=${encodeURIComponent(sessionId)}`).then((value) => {
952
- if (alive) setLog(value.entries);
953
- })
954
- ]).finally(() => {
952
+ setLogPage(1);
953
+ setRows(null);
954
+ void request(`logs?sessionId=${encodeURIComponent(sessionId)}`).then((value) => {
955
+ if (alive) {
956
+ setAnalysis(value);
957
+ seed(value);
958
+ }
959
+ }).catch(() => {
960
+ }).finally(() => {
955
961
  if (alive) setLoaded(true);
956
962
  });
957
963
  return () => {
958
964
  alive = false;
959
965
  };
960
966
  }, [sessionId, seed]);
967
+ (0, import_react3.useEffect)(() => {
968
+ let alive = true;
969
+ void request(`logrows?sessionId=${encodeURIComponent(sessionId)}&page=${logPage}&pageSize=${LOG_PAGE_SIZE}`).then((value) => {
970
+ if (alive) setRows(value);
971
+ }).catch(() => {
972
+ });
973
+ return () => {
974
+ alive = false;
975
+ };
976
+ }, [sessionId, logPage]);
961
977
  const profile = analysis?.profile;
962
978
  const backtest = analysis?.trendBacktest;
963
979
  const annotations = analysis?.annotations;
@@ -1021,8 +1037,13 @@ function AnalysisView({ sessionId, t }) {
1021
1037
  ] }),
1022
1038
  /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("section", { className: "decision-analysis-section", children: [
1023
1039
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("h3", { children: t("log") }),
1024
- log.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "decision-empty", role: "status", children: t("analysisLogTruncated") }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [
1025
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(DecisionLogList, { entries: log, ratings, onRate: (id, rating) => void annotate(id, rating), t }),
1040
+ !rows || rows.total === 0 ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "decision-empty", role: "status", children: t("analysisLogTruncated") }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [
1041
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(DecisionLogList, { entries: rows.entries, ratings, onRate: (id, rating) => void annotate(id, rating), t, newestFirst: false }),
1042
+ rows.pages > 1 && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("nav", { className: "decision-pager", "aria-label": t("log"), children: [
1043
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", disabled: rows.page <= 1, onClick: () => setLogPage((page) => Math.max(1, page - 1)), children: t("pagerPrev") }),
1044
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "decision-pager-status", children: t("pagerStatus", { page: rows.page, pages: rows.pages, total: rows.total }) }),
1045
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", disabled: rows.page >= rows.pages, onClick: () => setLogPage((page) => page + 1), children: t("pagerNext") })
1046
+ ] }),
1026
1047
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "decision-muted decision-analysis-note", children: t("analysisLogNote") })
1027
1048
  ] })
1028
1049
  ] })
@@ -1566,13 +1587,13 @@ var css = `
1566
1587
  .decision-dot-deny{background:#c0392b}
1567
1588
  .decision-card-foot{display:flex;justify-content:space-between;gap:1rem;margin-top:.7rem;padding-top:.6rem;border-top:1px solid var(--dsw-alias-border-l2,#eef1f3);font-size:.76rem;color:var(--dsw-alias-label-secondary,#59636e)}
1568
1589
  .decision-card-foot b{color:var(--dsw-alias-label-primary,#202124);font-variant-numeric:tabular-nums;margin-left:.3rem}
1569
- .decision-card-stats{display:flex;gap:.6rem}
1570
- .decision-stat{flex:1 1 0;display:flex;flex-direction:column;align-items:center;gap:.2rem;padding:.5rem .3rem;border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f6f8fa)}
1571
- .decision-stat-num{font-size:1.35rem;font-weight:700;line-height:1;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary,#202124)}
1590
+ .decision-card-stats{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:.6rem;align-items:stretch}
1591
+ .decision-stat{min-width:0;display:flex;flex-direction:column;align-items:center;justify-content:space-between;gap:.2rem;padding:.5rem .3rem;border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f6f8fa)}
1592
+ .decision-stat-num{font-size:1.35rem;font-weight:700;line-height:1;white-space:nowrap;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary,#202124)}
1572
1593
  .decision-stat-pct{font-size:.74rem;font-weight:600;line-height:1;color:var(--dsw-alias-label-tertiary,#77818b);font-variant-numeric:tabular-nums}
1573
1594
  .decision-stat-warn{color:#c98a1e}
1574
1595
  .decision-stat-muted{color:var(--dsw-alias-label-tertiary,#9aa5b1)}
1575
- .decision-stat-label{font-size:.72rem;color:var(--dsw-alias-label-secondary,#59636e)}
1596
+ .decision-stat-label{font-size:.72rem;line-height:1.25;text-align:center;color:var(--dsw-alias-label-secondary,#59636e)}
1576
1597
  .decision-log{list-style:none;margin:0;padding:0;display:grid;gap:.5rem;max-height:13rem;overflow:auto}
1577
1598
  .decision-log-item{display:flex;align-items:baseline;gap:.6rem;font-size:.8rem;padding-left:.6rem;border-left:2px solid var(--dsw-alias-border-l2,#dce1e6);line-height:1.5}
1578
1599
  .decision-log-item.decision-log-deny{border-left-color:#c0392b}
@@ -1693,9 +1714,16 @@ var css = `
1693
1714
  .decision-analysis-section{border-top:1px solid var(--dsw-alias-border-l2,#eceff1);margin-top:1.1rem;padding-top:1rem}
1694
1715
  .decision-analysis-section>h3{font-size:.92rem;margin:0 0 .6rem}
1695
1716
  .decision-analysis-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(13rem,1fr));gap:.7rem}
1696
- .decision-analysis-cards .decision-card-stats{flex-wrap:wrap}
1697
1717
  .decision-analysis-trend{max-width:26rem}
1698
1718
  .decision-analysis-note{margin:.5rem 0 0}
1719
+ /* The analysis tab's decision log shares the page scroll (no inner scrollbar);
1720
+ the modal panel's .decision-log keeps its own capped scroll via the base rule. */
1721
+ .decision-analysis .decision-log{max-height:none;overflow:visible}
1722
+ .decision-pager{display:flex;align-items:center;justify-content:center;gap:.8rem;margin-top:.8rem}
1723
+ .decision-pager button{border:1px solid var(--dsw-alias-border-l2,#ccd3da);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#59636e);padding:.3rem .85rem;font:inherit;font-size:.8rem;cursor:pointer}
1724
+ .decision-pager button:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1,#f2f4f6);color:var(--dsw-alias-label-primary,#202124)}
1725
+ .decision-pager button:disabled{opacity:.45;cursor:default}
1726
+ .decision-pager-status{font-size:.8rem;color:var(--dsw-alias-label-secondary,#59636e);font-variant-numeric:tabular-nums}
1699
1727
  @media (prefers-color-scheme:dark){
1700
1728
  .decision-analysis{color:var(--dsw-alias-label-primary,#e6eaee)}
1701
1729
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-decision-layer",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, tool narrowing, and loop guard backed by a pluggable adjudication model.",
5
5
  "keywords": [
6
6
  "dsh",
package/service/dsh.mjs CHANGED
@@ -11,7 +11,7 @@ import { createTaskCompletion } from './task-completion.mjs';
11
11
  import { createLoopGuard } from './loop-guard.mjs';
12
12
  import { createLogStore } from './log-store.mjs';
13
13
  import { createStateStore } from './state-store.mjs';
14
- import { analyzeSessionLogs, reconstructSessionSeed } from './log-analyze.mjs';
14
+ import { analyzeSessionLogs, reconstructSessionSeed, listSessionDecisions } from './log-analyze.mjs';
15
15
 
16
16
  export const name = 'dsh-decision-layer';
17
17
  export const inject = ['tools', 'skills'];
@@ -261,7 +261,8 @@ export async function apply(ctx, options = {}) {
261
261
  port,
262
262
  trendConfig: (await resolveConfig({ path })).checkSettings,
263
263
  });
264
- const unregister = createManagerRoutes({ path, sessions, backend, logStore, analyze, bindHost: web.webServer.host }).map(route => web.webServer.register(route));
264
+ const listRows = (sessionId, { page, pageSize } = {}) => listSessionDecisions(sessionId, { port, page, pageSize });
265
+ const unregister = createManagerRoutes({ path, sessions, backend, logStore, analyze, listRows, bindHost: web.webServer.host }).map(route => web.webServer.register(route));
265
266
  return () => { unregister.forEach(dispose => dispose()); const store = logStore; logStore = undefined; void store?.dispose(); };
266
267
  });
267
268
  }
@@ -230,3 +230,27 @@ export async function reconstructSessionSeed(sessionId, { dir, port, trendConfig
230
230
  if (decisions.size === 0) return undefined;
231
231
  return buildSeed(decisions, { maxLog, trendConfig });
232
232
  }
233
+
234
+ // Read one session's persisted decision rows and return a single newest-first
235
+ // page. Backs the analysis tab's full decision log (the dock panel still shows
236
+ // only the in-memory recent rows). Decisions are folded by id (latest wins) so a
237
+ // re-logged id is not double-counted. Rows carry only the panel fields — tool
238
+ // name, outcome, counts — never gate command/path, which is never logged. The
239
+ // internal sessionId tag is stripped before the row leaves the server.
240
+ export async function listSessionDecisions(sessionId, { dir, port, page = 1, pageSize = 100, readStream = createReadableStream } = {}) {
241
+ if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('Invalid session id');
242
+ const size = Number.isSafeInteger(pageSize) && pageSize > 0 && pageSize <= 500 ? pageSize : 100;
243
+ const records = [];
244
+ for await (const record of readStream({ dir, port })) {
245
+ if (!isObject(record)) continue;
246
+ if (record.sessionId === sessionId) records.push(record);
247
+ }
248
+ const { decisions } = foldSession(records);
249
+ const rows = [...decisions.values()].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)); // newest first
250
+ const total = rows.length;
251
+ const pages = Math.max(1, Math.ceil(total / size));
252
+ const current = Math.min(Math.max(1, Number.isSafeInteger(page) ? page : 1), pages);
253
+ const start = (current - 1) * size;
254
+ const entries = rows.slice(start, start + size).map(({ sessionId: _sessionId, ...rest }) => rest);
255
+ return { entries, total, page: current, pageSize: size, pages };
256
+ }
@@ -37,7 +37,7 @@ async function readBody(req) {
37
37
  return value;
38
38
  }
39
39
 
40
- export function createManagerRoutes({ path, sessions, backend, logStore, analyze, env = process.env, bindHost = '127.0.0.1' }) {
40
+ export function createManagerRoutes({ path, sessions, backend, logStore, analyze, listRows, env = process.env, bindHost = '127.0.0.1' }) {
41
41
  const route = (suffix, methods) => ({ kind: 'exact', path: `${API_PREFIX}/${suffix}`, handler: async (req, res) => {
42
42
  if (bindHost !== '127.0.0.1' || !authorizedBrowser(req)) return respond(res, 403, { ok: false, error: 'Local same-origin browser request required' });
43
43
  if (!Object.hasOwn(methods, req.method)) return respond(res, 405, { ok: false, error: 'Method not allowed' });
@@ -90,6 +90,21 @@ export function createManagerRoutes({ path, sessions, backend, logStore, analyze
90
90
  catch (error) { if (error?.code === 'BAD_REQUEST') throw error; const wrapped = new Error('analysis failed'); wrapped.code = 'INTERNAL'; throw wrapped; }
91
91
  },
92
92
  }),
93
+ // One newest-first page of this session's full persisted decision rows, for
94
+ // the analysis tab's paginated log. Rows carry only panel fields (tool name,
95
+ // outcome, counts) — never gate command/path, which is never logged — so this
96
+ // exposes nothing the recent-rows /log route does not already.
97
+ route('logrows', {
98
+ GET: async req => {
99
+ if (typeof listRows !== 'function') { const error = new Error('log rows unavailable'); error.code = 'BAD_REQUEST'; throw error; }
100
+ const id = sessionId(req);
101
+ const params = new URL(req.url ?? '/', 'http://localhost').searchParams;
102
+ const page = Number.parseInt(params.get('page') ?? '1', 10);
103
+ const pageSize = Number.parseInt(params.get('pageSize') ?? '100', 10);
104
+ try { return await listRows(id, { page: Number.isNaN(page) ? 1 : page, pageSize: Number.isNaN(pageSize) ? 100 : pageSize }); }
105
+ catch (error) { if (error?.code === 'BAD_REQUEST') throw error; const wrapped = new Error('log rows failed'); wrapped.code = 'INTERNAL'; throw wrapped; }
106
+ },
107
+ }),
93
108
  // Append one annotation event for a decision. Explicit user intent: validated
94
109
  // and written immediately; a write failure surfaces as an error, not silence.
95
110
  route('annotate', {