dsh-session-eval 0.2.3 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## 0.3.0 — 2026-10-23
2
+
3
+ Feature release: configurable grade bands (#1) and the history time window (#2).
4
+
5
+ - New: a `gradeBands` config block overrides the default thresholds for any dimension, per-profile — `reliability` in error-rate percent, `reask` in corrections per 10 user turns, `toolLoad` as `[lowC, lowB, highC, highD]` calls per turn. Invalid config (unknown keys, wrong arity, non-numeric, negative, non-ascending) fails fast at plugin load with a readable error; defaults are unchanged when nothing is configured.
6
+ - New: `/eval-history --since 24h|7d|30d` grades only sessions whose latest activity falls inside the window — filtered by last event time, not creation time. An empty window prints a clear message; without the flag the behavior is exactly as before.
7
+
8
+ ## 0.2.4 — 2026-10-11
9
+
10
+ Feature release: eval-history report files.
11
+
12
+ - New: `/eval-history --out PATH[.md|.json]` writes the history report to a Markdown or JSON file instead of only echoing it (contributed by @varshith84 in #4, merged as 117f918 after a maintainer rebase).
13
+
14
+ # Changelog
15
+
16
+
1
17
  ## 0.2.3 — 2026-10-03
2
18
 
3
19
  Docs polish round (no runtime changes).
@@ -7,8 +23,6 @@ Docs polish round (no runtime changes).
7
23
  - Community section; bilingual issue templates (`.github/ISSUE_TEMPLATE/`).
8
24
  - English/Chinese README sections kept in sync.
9
25
 
10
- # Changelog
11
-
12
26
  ## 0.2.2 — 2026-10-02
13
27
 
14
28
  Contributor-first docs release.
package/README.md CHANGED
@@ -65,7 +65,7 @@ All three read through the same trusted `ctx.sessionQuery` seam, so any persiste
65
65
 
66
66
  - **Local dev**: `npm ci && npm run typecheck && npm test && npm run bundle` (Node 20 or 22).
67
67
  - **Start in the source**: [`src/grade.ts`](src/grade.ts) (thresholds, grade cards), [`src/metrics.ts`](src/metrics.ts) (metric extraction), [`src/trend.ts`](src/trend.ts) (`/eval-history` trend logic). See [CONTRIBUTING.md](CONTRIBUTING.md) for the source map and first-PR suggestions.
68
- - **Open gaps**: [#1](https://github.com/kittimzhe/dsh-session-eval/issues/1) (configurable thresholds), [#2](https://github.com/kittimzhe/dsh-session-eval/issues/2) (`/eval-history --since`), [#3](https://github.com/kittimzhe/dsh-session-eval/issues/3) (`/eval-history --out`) — or browse [issues labeled `good first issue`](https://github.com/kittimzhe/dsh-session-eval/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
68
+ - **Open gaps**: [#1](https://github.com/kittimzhe/dsh-session-eval/issues/1) (configurable thresholds) and [#2](https://github.com/kittimzhe/dsh-session-eval/issues/2) (`/eval-history --since`) shipped in v0.3.0 — browse [issues labeled `good first issue`](https://github.com/kittimzhe/dsh-session-eval/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
69
69
  - **Rules**: behavior changes need tests; doc changes must update `README.md` and `README.zh.md` in sync; version releases belong to the maintainer. Details: [CONTRIBUTING.md](CONTRIBUTING.md).
70
70
 
71
71
  ## How it differs from benchmark-style evaluation
@@ -90,9 +90,28 @@ Both are useful; they don't compete. Benchmarks test what you *predicted*; retro
90
90
  | `/eval-diff … --json` | Machine-readable trend report |
91
91
  | `/eval-history [N]` | Grade the last N sessions in this workspace and show the trend |
92
92
  | `/eval-history … --json` | Machine-readable history report |
93
+ | `/eval-history --since 7d` | Grade only sessions with activity inside the window (`24h`, `30d` also parse) |
94
+
95
+ `--since` filters by each session's **latest activity**, not its creation time: a session opened long ago but active an hour ago stays in the window; a freshly created but silent one drops out. With `--since 7d` the trend compares the first and last of the in-window set, and an empty window prints a clear message instead of a crash. Without the flag, behavior is exactly as before (last N sessions).
96
+
97
+ Use `/eval-history --out report.md` to save the terminal card as Markdown, or add `--json` to save the JSON payload. Relative paths resolve against the current session workspace (the process working directory when unavailable). Parent directories must already exist; file errors are reported without creating directories. Existing output files are overwritten.
93
98
 
94
99
  `/eval-history` finds the sessions for you via the same `sessionQuery` seam `/archive` uses (scoped to the current workspace's `cwd`), grades each, and diffs the first against the last. `/eval-diff` remains the tool when you already know the two ids that matter.
95
100
 
101
+ ## Configuring grade bands
102
+
103
+ The thresholds are defaults, not laws. A `gradeBands` block under the plugin's config node (naturally per-profile) overrides any dimension — values are the A–D upper bounds, ascending:
104
+
105
+ ```yaml
106
+ session-eval:
107
+ gradeBands:
108
+ reliability: [4, 8, 15, 30] # tool error-rate percent
109
+ reask: [0, 2, 4, 8] # corrections per 10 user turns
110
+ toolLoad: [0.1, 0.5, 8, 20] # tool calls per turn: [lowC, lowB, highC, highD]
111
+ ```
112
+
113
+ Any subset may be overridden; unlisted dimensions keep the documented defaults. Unknown keys, wrong arity, non-numeric, negative, or non-ascending values fail fast at plugin load with a readable error — sessions are never graded against bands nobody chose.
114
+
96
115
  Typical use: grade the ten sessions before and after a plugin or prompt change; a persistent `Reliability` regression is an early warning that the change hurt real work, not just benchmarks.
97
116
 
98
117
  ## Dimensions & thresholds
package/README.zh.md CHANGED
@@ -65,7 +65,7 @@ Notes:
65
65
 
66
66
  - **本地开发**:`npm ci && npm run typecheck && npm test && npm run bundle`(Node 20 或 22)。
67
67
  - **源码入口**:[`src/grade.ts`](src/grade.ts)(阈值与等级卡)、[`src/metrics.ts`](src/metrics.ts)(指标提取)、[`src/trend.ts`](src/trend.ts)(`/eval-history` 趋势逻辑)。源码地图与第一次 PR 建议见 [CONTRIBUTING.md](CONTRIBUTING.md)。
68
- - **当前缺口**:[#1](https://github.com/kittimzhe/dsh-session-eval/issues/1)(阈值可配置)、[#2](https://github.com/kittimzhe/dsh-session-eval/issues/2)(`/eval-history --since`)、[#3](https://github.com/kittimzhe/dsh-session-eval/issues/3)(`/eval-history --out`)——或浏览 [`good first issue` 标签](https://github.com/kittimzhe/dsh-session-eval/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)。
68
+ - **当前缺口**:[#1](https://github.com/kittimzhe/dsh-session-eval/issues/1)(阈值可配置)与 [#2](https://github.com/kittimzhe/dsh-session-eval/issues/2)(`/eval-history --since`)已在 v0.3.0 落地——或浏览 [`good first issue` 标签](https://github.com/kittimzhe/dsh-session-eval/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)。
69
69
  - **规矩**:行为变更必须带测试;文档必须 `README.md` 与 `README.zh.md` 同步改;版本发布由维护者执行。详见 [CONTRIBUTING.md](CONTRIBUTING.md)。
70
70
 
71
71
  ## 与基准式评测(dsh-eval)的区别
@@ -90,11 +90,30 @@ Notes:
90
90
  | `/eval-diff … --json` | 机器可读趋势报告 |
91
91
  | `/eval-history [N]` | 本工作区最近 N 个会话成绩单 + 首末趋势 |
92
92
  | `/eval-history … --json` | 机器可读历史报告 |
93
+ | `/eval-history --since 7d` | 只评窗口内有活动的会话(也支持 `24h`、`30d`) |
94
+
95
+ `--since` 按**最新活动时间**过滤,而非创建时间:很久前打开但一小时前还活跃的会话留在窗口内;刚创建但一直沉默的会被排除。`--since 7d` 下趋势对比的是窗口内首末两个会话;窗口为空时打印明确提示而不是崩溃。不加该参数时行为与从前完全一致(最近 N 个)。
96
+
97
+ 使用 `/eval-history --out report.md` 将终端卡片保存为 Markdown,添加 `--json` 则保存 JSON。相对路径基于当前会话工作目录(缺失时使用进程工作目录)。父目录必须已存在;写入失败会返回错误,不会创建目录。已存在的输出文件会被覆盖。
93
98
 
94
99
  `/eval-history` 通过与 `/archive` 相同的 `sessionQuery` 接缝自动找到会话(按当前工作区 `cwd` 过滤),逐个评级,并把首尾两个会话做趋势对比——不用再手动挑 id。已经知道两个关键 id 时用 `/eval-diff`。
95
100
 
96
101
  典型用法:换插件或 prompt 前后各评十个会话;`Reliability` 持续回归 = 改动伤害了真实工作的早期预警。
97
102
 
103
+ ## 配置等级阈值
104
+
105
+ 阈值是默认值而非铁律。在插件配置节点下(天然按 profile 区分)加 `gradeBands` 块即可覆盖任意维度——值为 A–D 各档上限、升序:
106
+
107
+ ```yaml
108
+ session-eval:
109
+ gradeBands:
110
+ reliability: [4, 8, 15, 30] # 工具错误率百分比
111
+ reask: [0, 2, 4, 8] # 每 10 个用户轮次的纠错次数
112
+ toolLoad: [0.1, 0.5, 8, 20] # 每轮工具调用数:[lowC, lowB, highC, highD]
113
+ ```
114
+
115
+ 可只覆盖部分维度;未列出的保持文档默认值。未知键、元素个数不对、非数字、负数或非升序,都会在插件加载时以可读错误快速失败——绝不会对着没人选过的阈值评级。
116
+
98
117
  ## 维度与阈值(确定性,无裁判)
99
118
 
100
119
  | 维度 | 指标 | A | B | C | D | E |
package/lib/index.d.ts CHANGED
@@ -92,8 +92,23 @@ declare function computeMetrics(sessionId: string, events: readonly EvalEvent[])
92
92
  declare function adaptEvents(raw: readonly unknown[]): EvalEvent[];
93
93
  //#endregion
94
94
  //#region src/grade.d.ts
95
- /** Grade one session deterministically. */
96
- declare function gradeSession(m: SessionMetrics): GradeCard;
95
+ /**
96
+ * Grade bands per dimension, overridable from the plugin config (#1).
97
+ * Each array holds the A/B/C/D upper bounds in ascending order; anything
98
+ * above the D bound grades E. Reliability bounds are error-rate fractions,
99
+ * re-ask bounds are corrections per 10 user turns, tool-load bounds are
100
+ * calls per turn as [lowC, lowB, highC, highD].
101
+ */
102
+ interface GradeBands {
103
+ readonly reliability: readonly [number, number, number, number];
104
+ readonly reask: readonly [number, number, number, number];
105
+ readonly toolLoad: readonly [number, number, number, number];
106
+ }
107
+ /**
108
+ * Grade one session deterministically. `bands` may override any subset of
109
+ * the dimensions; unspecified ones keep the documented defaults.
110
+ */
111
+ declare function gradeSession(m: SessionMetrics, bands?: Partial<GradeBands>): GradeCard;
97
112
  //#endregion
98
113
  //#region src/trend.d.ts
99
114
  /** Compare two sessions dimension by dimension. */
@@ -128,16 +143,22 @@ interface QuerySeam {
128
143
  }>;
129
144
  }
130
145
  /** Execute /eval against the session-query seam. */
131
- declare function executeEval(ctx: Context, invocation: CommandInvocation, seam?: QuerySeam): Promise<CommandResult>;
146
+ declare function executeEval(ctx: Context, invocation: CommandInvocation, seam?: QuerySeam, bands?: Partial<GradeBands>): Promise<CommandResult>;
132
147
  /** Execute /eval-diff against the session-query seam. */
133
148
  declare function executeEvalDiff(ctx: Context, invocation: CommandInvocation, seam?: QuerySeam): Promise<CommandResult>;
134
149
  //#endregion
135
150
  //#region src/historyCommand.d.ts
136
- declare const EVAL_HISTORY_USAGE = "Usage: /eval-history [N] [--json] (N = last N sessions, 1-20, default 5)";
151
+ declare const EVAL_HISTORY_USAGE = "Usage: /eval-history [N] [--json] [--out PATH] [--since 24h|7d|30d] (N = last N sessions, 1-20, default 5; --since grades only sessions whose latest activity falls inside the window)";
137
152
  /** Args for /eval-history. */
138
153
  interface EvalHistoryArgs {
139
154
  readonly count: number;
140
155
  readonly json: boolean;
156
+ readonly out?: string;
157
+ /** Time window from --since; grades only sessions whose latest activity falls inside. */
158
+ readonly since?: {
159
+ label: string;
160
+ ms: number;
161
+ };
141
162
  }
142
163
  /** Parse /eval-history input; returns args or a usage-error string. */
143
164
  declare function parseEvalHistoryArgs(rawInput: string): EvalHistoryArgs | string;
@@ -152,6 +173,8 @@ interface HistoryEntry {
152
173
  interface HistoryReport {
153
174
  readonly entries: readonly HistoryEntry[];
154
175
  readonly trend: TrendReport | null;
176
+ /** The --since label this report was filtered by, if any. */
177
+ readonly sinceLabel?: string;
155
178
  }
156
179
  /** The slice of sessionQuery this command consumes (structural, for testability). */
157
180
  interface HistorySeam {
@@ -169,7 +192,7 @@ interface HistorySeam {
169
192
  /** Render a history report as terminal text. */
170
193
  declare function renderHistory(report: HistoryReport): string;
171
194
  /** Execute /eval-history against the session-query seam. */
172
- declare function executeEvalHistory(ctx: Context, invocation: CommandInvocation, seam?: HistorySeam): Promise<CommandResult>;
195
+ declare function executeEvalHistory(ctx: Context, invocation: CommandInvocation, seam?: HistorySeam, bands?: Partial<GradeBands>): Promise<CommandResult>;
173
196
  //#endregion
174
197
  //#region src/version.d.ts
175
198
  /**
@@ -177,12 +200,18 @@ declare function executeEvalHistory(ctx: Context, invocation: CommandInvocation,
177
200
  *
178
201
  * @module dsh-session-eval/version
179
202
  */
180
- declare const VERSION = "0.2.3";
203
+ declare const VERSION = "0.3.0";
181
204
  //#endregion
182
205
  //#region src/index.d.ts
183
206
  declare const name = "session-eval";
184
207
  declare const inject: string[];
185
- /** Plugin entry: mount the /eval, /eval-diff and /eval-history commands. */
186
- declare function apply(ctx: Context): void;
208
+ /**
209
+ * Plugin entry: mount the /eval, /eval-diff and /eval-history commands.
210
+ *
211
+ * `config` is the plugin's node in the host config tree (per-profile): its
212
+ * `gradeBands` block overrides the default grade thresholds — see config.ts.
213
+ * Invalid config fails fast here, at plugin load, rather than mid-grade.
214
+ */
215
+ declare function apply(ctx: Context, config?: unknown): void;
187
216
  //#endregion
188
217
  export { type DimensionDelta, type DimensionGrade, EVAL_DIFF_USAGE, EVAL_HISTORY_USAGE, EVAL_USAGE, type EvalArgs, type EvalDiffArgs, type EvalEvent, type EvalEventKind, type EvalHistoryArgs, type Grade, type GradeCard, type HistoryEntry, type HistoryReport, type HistorySeam, type SessionMetrics, type TrendReport, VERSION, adaptEvents, apply, compareSessions, computeMetrics, executeEval, executeEvalDiff, executeEvalHistory, gradeSession, id8, inject, name, parseEvalArgs, parseEvalDiffArgs, parseEvalHistoryArgs, renderCard, renderHistory, renderTrend };
package/lib/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { SessionId } from "@deepseek-ai/dsh-session";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
2
4
  //#region src/metrics.ts
3
5
  /** Count a correction signal for every extra user message inside a consecutive-user run. */
4
6
  function countCorrections(events) {
@@ -84,27 +86,35 @@ function readBool(source, key) {
84
86
  }
85
87
  //#endregion
86
88
  //#region src/grade.ts
87
- /** Reliability thresholds on tool error rate (fraction). */
88
- const RELIABILITY = [
89
- ["A", .05],
90
- ["B", .1],
91
- ["C", .2],
92
- ["D", .35]
93
- ];
94
- /** Re-ask thresholds: corrections per 10 user turns. */
95
- const REASK = [
96
- ["A", 0],
97
- ["B", 1],
98
- ["C", 2],
99
- ["D", 4]
100
- ];
101
- /** Tool-load thresholds: tool calls per user turn (both churn extremes graded). */
102
- const TOOL_LOAD_LOW_B = .5;
103
- const TOOL_LOAD_LOW_C = .1;
104
- const TOOL_LOAD_HIGH_C = 8;
105
- const TOOL_LOAD_HIGH_D = 20;
106
- function gradeBy(rate, table) {
107
- for (const [grade, max] of table) if (rate <= max) return grade;
89
+ /** The documented defaults; unchanged when no config is given. */
90
+ const DEFAULT_GRADE_BANDS = {
91
+ reliability: [
92
+ .05,
93
+ .1,
94
+ .2,
95
+ .35
96
+ ],
97
+ reask: [
98
+ 0,
99
+ 1,
100
+ 2,
101
+ 4
102
+ ],
103
+ toolLoad: [
104
+ .1,
105
+ .5,
106
+ 8,
107
+ 20
108
+ ]
109
+ };
110
+ function gradeBy(rate, bands) {
111
+ const grades = [
112
+ "A",
113
+ "B",
114
+ "C",
115
+ "D"
116
+ ];
117
+ for (let i = 0; i < bands.length; i++) if (rate <= bands[i]) return grades[i];
108
118
  return "E";
109
119
  }
110
120
  const ORDER = [
@@ -120,8 +130,15 @@ function worst(a, b) {
120
130
  function pct(rate) {
121
131
  return `${(rate * 100).toFixed(1)}%`;
122
132
  }
123
- /** Grade one session deterministically. */
124
- function gradeSession(m) {
133
+ /**
134
+ * Grade one session deterministically. `bands` may override any subset of
135
+ * the dimensions; unspecified ones keep the documented defaults.
136
+ */
137
+ function gradeSession(m, bands = {}) {
138
+ const b = {
139
+ ...DEFAULT_GRADE_BANDS,
140
+ ...bands
141
+ };
125
142
  const dimensions = [];
126
143
  const notes = [];
127
144
  if (m.toolCalls === 0) {
@@ -132,7 +149,7 @@ function gradeSession(m) {
132
149
  });
133
150
  notes.push("Reliability is n/a — the session made no tool calls.");
134
151
  } else {
135
- const grade = gradeBy(m.toolErrorRate, RELIABILITY);
152
+ const grade = gradeBy(m.toolErrorRate, b.reliability);
136
153
  dimensions.push({
137
154
  dimension: "Reliability",
138
155
  grade,
@@ -145,7 +162,7 @@ function gradeSession(m) {
145
162
  detail: "no user turns"
146
163
  });
147
164
  else {
148
- const grade = gradeBy(m.correctionRate, REASK);
165
+ const grade = gradeBy(m.correctionRate, b.reask);
149
166
  dimensions.push({
150
167
  dimension: "Re-ask",
151
168
  grade,
@@ -158,9 +175,10 @@ function gradeSession(m) {
158
175
  detail: "no tool activity"
159
176
  });
160
177
  else {
178
+ const [lowC, lowB, highC, highD] = b.toolLoad;
161
179
  let grade = "A";
162
- if (m.toolLoad < TOOL_LOAD_LOW_B) grade = m.toolLoad < TOOL_LOAD_LOW_C ? "C" : "B";
163
- if (m.toolLoad > TOOL_LOAD_HIGH_C) grade = worst(grade, m.toolLoad > TOOL_LOAD_HIGH_D ? "D" : "C");
180
+ if (m.toolLoad < lowB) grade = m.toolLoad < lowC ? "C" : "B";
181
+ if (m.toolLoad > highC) grade = worst(grade, m.toolLoad > highD ? "D" : "C");
164
182
  dimensions.push({
165
183
  dimension: "Tool load",
166
184
  grade,
@@ -227,7 +245,7 @@ function compareSessions(before, after) {
227
245
  *
228
246
  * @module dsh-session-eval/version
229
247
  */
230
- const VERSION = "0.2.3";
248
+ const VERSION = "0.3.0";
231
249
  //#endregion
232
250
  //#region src/evalCommand.ts
233
251
  const EVAL_USAGE = "Usage: /eval [--id <sessionId>] [--json]";
@@ -310,7 +328,7 @@ async function readEvents(ctx, seam, sessionIdRaw) {
310
328
  }
311
329
  }
312
330
  /** Execute /eval against the session-query seam. */
313
- async function executeEval(ctx, invocation, seam = ctx.sessionQuery) {
331
+ async function executeEval(ctx, invocation, seam = ctx.sessionQuery, bands = {}) {
314
332
  const parsed = parseEvalArgs(invocation.rawInput);
315
333
  if (typeof parsed === "string") return {
316
334
  kind: "error",
@@ -323,18 +341,18 @@ async function executeEval(ctx, invocation, seam = ctx.sessionQuery) {
323
341
  text: events
324
342
  };
325
343
  const metrics = computeMetrics(sessionIdRaw, adaptEvents(events));
326
- const card = gradeSession(metrics);
344
+ const card = gradeSession(metrics, bands);
327
345
  if (parsed.json === true) return {
328
346
  kind: "success",
329
347
  text: JSON.stringify({
330
- generator: "dsh-session-eval v0.2.3",
348
+ generator: "dsh-session-eval v0.3.0",
331
349
  metrics,
332
350
  card
333
351
  }, null, 2)
334
352
  };
335
353
  return {
336
354
  kind: "success",
337
- text: renderCard(card, "dsh-session-eval v0.2.3")
355
+ text: renderCard(card, "dsh-session-eval v0.3.0")
338
356
  };
339
357
  }
340
358
  /** Execute /eval-diff against the session-query seam. */
@@ -358,25 +376,58 @@ async function executeEvalDiff(ctx, invocation, seam = ctx.sessionQuery) {
358
376
  if (parsed.json === true) return {
359
377
  kind: "success",
360
378
  text: JSON.stringify({
361
- generator: "dsh-session-eval v0.2.3",
379
+ generator: "dsh-session-eval v0.3.0",
362
380
  report
363
381
  }, null, 2)
364
382
  };
365
383
  return {
366
384
  kind: "success",
367
- text: renderTrend(report, "dsh-session-eval v0.2.3")
385
+ text: renderTrend(report, "dsh-session-eval v0.3.0")
368
386
  };
369
387
  }
370
388
  //#endregion
371
389
  //#region src/historyCommand.ts
372
- const EVAL_HISTORY_USAGE = "Usage: /eval-history [N] [--json] (N = last N sessions, 1-20, default 5)";
390
+ /**
391
+ * `/eval-history` — grade the last N sessions in this workspace and show the trend.
392
+ *
393
+ * Completes the regression-tracking story: `/eval-diff` compares two sessions
394
+ * you already know about; this command finds them for you (via
395
+ * `ctx.sessionQuery.listSessions()`, same seam as `/archive` in
396
+ * dsh-session-export) and answers "is the trend improving?" out of the box.
397
+ *
398
+ * @module dsh-session-eval/historyCommand
399
+ */
400
+ const EVAL_HISTORY_USAGE = "Usage: /eval-history [N] [--json] [--out PATH] [--since 24h|7d|30d] (N = last N sessions, 1-20, default 5; --since grades only sessions whose latest activity falls inside the window)";
373
401
  /** Parse /eval-history input; returns args or a usage-error string. */
374
402
  function parseEvalHistoryArgs(rawInput) {
375
403
  const tokens = rawInput.trim().split(/\s+/).filter((t) => t.length > 0);
376
404
  let count = 5;
377
405
  let json = false;
378
406
  let sawCount = false;
379
- for (const token of tokens) {
407
+ let out;
408
+ let since;
409
+ for (let i = 0; i < tokens.length; i++) {
410
+ const token = tokens[i];
411
+ if (token === "--since") {
412
+ if (since !== void 0) return `--since may be given only once.\n${EVAL_HISTORY_USAGE}`;
413
+ const value = tokens[++i];
414
+ const match = /^(\d+)(h|d)$/.exec(value ?? "");
415
+ if (!match) return `--since needs a window like 24h or 7d.\n${EVAL_HISTORY_USAGE}`;
416
+ const amount = Number(match[1]);
417
+ if (amount < 1 || amount > 365) return `--since window is out of range.\n${EVAL_HISTORY_USAGE}`;
418
+ since = {
419
+ label: value,
420
+ ms: amount * (match[2] === "h" ? 36e5 : 864e5)
421
+ };
422
+ continue;
423
+ }
424
+ if (token === "--out") {
425
+ if (out !== void 0) return `--out may be given only once.\n${EVAL_HISTORY_USAGE}`;
426
+ const path = tokens[++i];
427
+ if (!path || path.startsWith("--")) return `--out requires a path.\n${EVAL_HISTORY_USAGE}`;
428
+ out = path;
429
+ continue;
430
+ }
380
431
  if (token === "--json") {
381
432
  if (json) return `--json may be given only once.\n${EVAL_HISTORY_USAGE}`;
382
433
  json = true;
@@ -392,7 +443,9 @@ function parseEvalHistoryArgs(rawInput) {
392
443
  }
393
444
  return {
394
445
  count,
395
- json
446
+ json,
447
+ ...out !== void 0 ? { out } : {},
448
+ ...since !== void 0 ? { since } : {}
396
449
  };
397
450
  }
398
451
  function toMillis(createdAt) {
@@ -410,7 +463,10 @@ function stamp(ms) {
410
463
  /** Render a history report as terminal text. */
411
464
  function renderHistory(report) {
412
465
  const lines = [];
413
- if (report.entries.length === 0) return "No sessions found in this workspace; nothing to grade.";
466
+ if (report.entries.length === 0) {
467
+ if (report.sinceLabel !== void 0) return `No sessions with activity in the last ${report.sinceLabel} in this workspace; nothing to grade.`;
468
+ return "No sessions found in this workspace; nothing to grade.";
469
+ }
414
470
  lines.push(`Session history — last ${report.entries.length} in this workspace`);
415
471
  for (const entry of report.entries) {
416
472
  const m = entry.metrics;
@@ -421,7 +477,7 @@ function renderHistory(report) {
421
477
  return lines.join("\n");
422
478
  }
423
479
  /** Execute /eval-history against the session-query seam. */
424
- async function executeEvalHistory(ctx, invocation, seam = ctx.sessionQuery) {
480
+ async function executeEvalHistory(ctx, invocation, seam = ctx.sessionQuery, bands = {}) {
425
481
  const parsed = parseEvalHistoryArgs(invocation.rawInput);
426
482
  if (typeof parsed === "string") return {
427
483
  kind: "error",
@@ -437,18 +493,37 @@ async function executeEvalHistory(ctx, invocation, seam = ctx.sessionQuery) {
437
493
  text: `Could not list sessions: ${error instanceof Error ? error.message : String(error)}`
438
494
  };
439
495
  }
440
- const recent = records.map((record) => record.header).filter((header) => header != null).filter((header) => ownCwd === void 0 || header.cwd === ownCwd).map((header) => ({
496
+ const headers = records.map((record) => record.header).filter((header) => header != null).filter((header) => ownCwd === void 0 || header.cwd === ownCwd).map((header) => ({
441
497
  id: String(header.id),
442
498
  createdAt: toMillis(header.createdAt)
443
- })).sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)).slice(-parsed.count);
444
- if (recent.length === 0) return {
445
- kind: "success",
446
- text: "No sessions found in this workspace; nothing to grade."
447
- };
499
+ })).sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
500
+ const activityCache = /* @__PURE__ */ new Map();
501
+ let recent = headers.slice(-parsed.count);
502
+ if (parsed.since !== void 0) {
503
+ const cutoff = Date.now() - parsed.since.ms;
504
+ const chosen = [];
505
+ for (let i = headers.length - 1; i >= 0 && chosen.length < parsed.count; i--) {
506
+ const header = headers[i];
507
+ let log;
508
+ try {
509
+ log = await seam.readSession(SessionId(header.id));
510
+ } catch (error) {
511
+ return {
512
+ kind: "error",
513
+ text: `Could not read session ${id8(header.id)}: ${error instanceof Error ? error.message : String(error)}`
514
+ };
515
+ }
516
+ activityCache.set(header.id, log.events);
517
+ if (adaptEvents(log.events).reduce((max, event) => Math.max(max, event.time), Number.NEGATIVE_INFINITY) >= cutoff) chosen.unshift(header);
518
+ }
519
+ recent = chosen;
520
+ }
448
521
  const entries = [];
449
522
  for (const header of recent) {
523
+ const cached = activityCache.get(header.id);
450
524
  let events;
451
- try {
525
+ if (cached !== void 0) events = cached;
526
+ else try {
452
527
  events = (await seam.readSession(SessionId(header.id))).events;
453
528
  } catch (error) {
454
529
  return {
@@ -461,36 +536,119 @@ async function executeEvalHistory(ctx, invocation, seam = ctx.sessionQuery) {
461
536
  sessionId: header.id,
462
537
  createdAt: header.createdAt,
463
538
  metrics,
464
- card: gradeSession(metrics)
539
+ card: gradeSession(metrics, bands)
465
540
  });
466
541
  }
467
542
  const report = {
468
543
  entries,
469
- trend: entries.length >= 2 ? compareSessions(entries[0].metrics, entries[entries.length - 1].metrics) : null
544
+ trend: entries.length >= 2 ? compareSessions(entries[0].metrics, entries[entries.length - 1].metrics) : null,
545
+ ...parsed.since !== void 0 ? { sinceLabel: parsed.since.label } : {}
470
546
  };
471
- if (parsed.json === true) return {
547
+ const text = parsed.json ? JSON.stringify({
548
+ generator: `dsh-session-eval v${VERSION}`,
549
+ report
550
+ }, null, 2) : renderHistory(report);
551
+ if (parsed.out !== void 0) {
552
+ const path = resolve(typeof ownCwd === "string" ? ownCwd : process.cwd(), parsed.out);
553
+ try {
554
+ const output = parsed.json ? text : `\`\`\`text\n${text}\n\`\`\``;
555
+ await writeFile(path, `${output}\n`, "utf8");
556
+ } catch (error) {
557
+ return {
558
+ kind: "error",
559
+ text: `Could not write history report to ${path}: ${error instanceof Error ? error.message : String(error)}`
560
+ };
561
+ }
562
+ }
563
+ return {
472
564
  kind: "success",
473
- text: JSON.stringify({
474
- generator: `dsh-session-eval v${VERSION}`,
475
- report
476
- }, null, 2)
565
+ text
477
566
  };
567
+ }
568
+ //#endregion
569
+ //#region src/config.ts
570
+ /**
571
+ * Plugin config parsing for dsh-session-eval.
572
+ *
573
+ * The config block lives under the plugin's node in the host config tree, so
574
+ * it is naturally per-profile: each profile can grade against its own bands.
575
+ *
576
+ * Shape (#1):
577
+ *
578
+ * ```yaml
579
+ * session-eval:
580
+ * gradeBands:
581
+ * reliability: [4, 8, 15, 30] # tool error-rate percent, A–D upper bounds
582
+ * reask: [0, 2, 4, 8] # corrections per 10 user turns
583
+ * toolLoad: [0.1, 0.5, 8, 20] # tool calls per turn: [lowC, lowB, highC, highD]
584
+ * ```
585
+ *
586
+ * Unknown keys, wrong arity, non-numeric, negative or non-ascending values
587
+ * fail fast with a readable error — a silent fallback would grade sessions
588
+ * against bands nobody chose.
589
+ *
590
+ * @module dsh-session-eval/config
591
+ */
592
+ const DIMENSIONS = [
593
+ "reliability",
594
+ "reask",
595
+ "toolLoad"
596
+ ];
597
+ const SHAPE = "4 ascending numbers: reliability = tool error-rate percent A–D upper bounds, reask = corrections per 10 user turns, toolLoad = [lowC, lowB, highC, highD] tool calls per turn";
598
+ function parseDimension(dimension, raw) {
599
+ const where = `gradeBands.${dimension}`;
600
+ if (raw.length !== 4) throw new Error(`${where} needs exactly ${SHAPE}`);
601
+ const values = raw.map((entry) => {
602
+ if (typeof entry !== "number" || !Number.isFinite(entry)) throw new Error(`${where} entries must each be a finite number (${SHAPE})`);
603
+ return entry;
604
+ });
605
+ for (const value of values) if (value < 0) throw new Error(`${where} entries must not be negative (${SHAPE})`);
606
+ for (let i = 1; i < values.length; i++) if (values[i] <= values[i - 1]) throw new Error(`${where} entries must be strictly ascending (${SHAPE})`);
607
+ return values;
608
+ }
609
+ /**
610
+ * Parse the plugin config into grade bands. Returns the defaults unchanged
611
+ * when nothing is configured; throws a readable Error on any invalid input.
612
+ */
613
+ function parseGradeBands(config) {
614
+ if (config === void 0 || config === null) return DEFAULT_GRADE_BANDS;
615
+ if (typeof config !== "object") throw new Error("session-eval config must be a mapping");
616
+ const root = config;
617
+ for (const key of Object.keys(root)) if (key !== "gradeBands") throw new Error(`Unknown session-eval config key "${key}" — only "gradeBands" is read`);
618
+ const raw = root.gradeBands;
619
+ if (raw === void 0) return DEFAULT_GRADE_BANDS;
620
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("session-eval gradeBands must be a mapping");
621
+ const overrides = {};
622
+ for (const key of Object.keys(raw)) if (!DIMENSIONS.includes(key)) throw new Error(`Unknown gradeBands dimension "${key}" — expected one of ${DIMENSIONS.join(", ")}`);
623
+ for (const dimension of DIMENSIONS) {
624
+ const entry = raw[dimension];
625
+ if (entry !== void 0) overrides[dimension] = parseDimension(dimension, entry);
626
+ }
627
+ const quad = (dimension) => overrides[dimension];
478
628
  return {
479
- kind: "success",
480
- text: renderHistory(report)
629
+ reliability: overrides.reliability !== void 0 ? overrides.reliability.map((v) => v / 100) : DEFAULT_GRADE_BANDS.reliability,
630
+ reask: overrides.reask !== void 0 ? quad("reask") : DEFAULT_GRADE_BANDS.reask,
631
+ toolLoad: overrides.toolLoad !== void 0 ? quad("toolLoad") : DEFAULT_GRADE_BANDS.toolLoad
481
632
  };
482
633
  }
483
634
  //#endregion
484
635
  //#region src/index.ts
485
636
  const name = "session-eval";
486
637
  const inject = ["commands", "sessionQuery"];
487
- /** Plugin entry: mount the /eval, /eval-diff and /eval-history commands. */
488
- function apply(ctx) {
638
+ /**
639
+ * Plugin entry: mount the /eval, /eval-diff and /eval-history commands.
640
+ *
641
+ * `config` is the plugin's node in the host config tree (per-profile): its
642
+ * `gradeBands` block overrides the default grade thresholds — see config.ts.
643
+ * Invalid config fails fast here, at plugin load, rather than mid-grade.
644
+ */
645
+ function apply(ctx, config) {
646
+ const bands = parseGradeBands(config);
489
647
  ctx.effect(function* () {
490
648
  yield ctx.commands.register({
491
649
  name: "eval",
492
650
  description: "Print a deterministic grade card for this session (or another via --id): reliability, re-ask, tool load",
493
- handler: (invocation) => executeEval(ctx, invocation)
651
+ handler: (invocation) => executeEval(ctx, invocation, void 0, bands)
494
652
  });
495
653
  yield ctx.commands.register({
496
654
  name: "eval-diff",
@@ -500,7 +658,7 @@ function apply(ctx) {
500
658
  yield ctx.commands.register({
501
659
  name: "eval-history",
502
660
  description: "Grade the last N sessions in this workspace (default 5) and show whether the trend is improving",
503
- handler: (invocation) => executeEvalHistory(ctx, invocation)
661
+ handler: (invocation) => executeEvalHistory(ctx, invocation, void 0, bands)
504
662
  });
505
663
  }, "session-eval lifecycle");
506
664
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-session-eval",
3
3
  "description": "Retrospective agent-session evaluation for DeepSeek Harness: deterministic grade cards from persisted session logs, cross-session regression diffs, era comparison — no benchmark runs, no LLM judges.",
4
- "version": "0.2.3",
4
+ "version": "0.3.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },