dsh-session-eval 0.2.4 → 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,10 @@
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
+
1
8
  ## 0.2.4 — 2026-10-11
2
9
 
3
10
  Feature release: eval-history report files.
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,11 +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).
93
96
 
94
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.
95
98
 
96
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.
97
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
+
98
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.
99
116
 
100
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,6 +90,9 @@ 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 个)。
93
96
 
94
97
  使用 `/eval-history --out report.md` 将终端卡片保存为 Markdown,添加 `--json` 则保存 JSON。相对路径基于当前会话工作目录(缺失时使用进程工作目录)。父目录必须已存在;写入失败会返回错误,不会创建目录。已存在的输出文件会被覆盖。
95
98
 
@@ -97,6 +100,20 @@ Notes:
97
100
 
98
101
  典型用法:换插件或 prompt 前后各评十个会话;`Reliability` 持续回归 = 改动伤害了真实工作的早期预警。
99
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
+
100
117
  ## 维度与阈值(确定性,无裁判)
101
118
 
102
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,17 +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] [--out PATH] (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;
141
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
+ };
142
162
  }
143
163
  /** Parse /eval-history input; returns args or a usage-error string. */
144
164
  declare function parseEvalHistoryArgs(rawInput: string): EvalHistoryArgs | string;
@@ -153,6 +173,8 @@ interface HistoryEntry {
153
173
  interface HistoryReport {
154
174
  readonly entries: readonly HistoryEntry[];
155
175
  readonly trend: TrendReport | null;
176
+ /** The --since label this report was filtered by, if any. */
177
+ readonly sinceLabel?: string;
156
178
  }
157
179
  /** The slice of sessionQuery this command consumes (structural, for testability). */
158
180
  interface HistorySeam {
@@ -170,7 +192,7 @@ interface HistorySeam {
170
192
  /** Render a history report as terminal text. */
171
193
  declare function renderHistory(report: HistoryReport): string;
172
194
  /** Execute /eval-history against the session-query seam. */
173
- 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>;
174
196
  //#endregion
175
197
  //#region src/version.d.ts
176
198
  /**
@@ -178,12 +200,18 @@ declare function executeEvalHistory(ctx: Context, invocation: CommandInvocation,
178
200
  *
179
201
  * @module dsh-session-eval/version
180
202
  */
181
- declare const VERSION = "0.2.4";
203
+ declare const VERSION = "0.3.0";
182
204
  //#endregion
183
205
  //#region src/index.d.ts
184
206
  declare const name = "session-eval";
185
207
  declare const inject: string[];
186
- /** Plugin entry: mount the /eval, /eval-diff and /eval-history commands. */
187
- 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;
188
216
  //#endregion
189
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
@@ -86,27 +86,35 @@ function readBool(source, key) {
86
86
  }
87
87
  //#endregion
88
88
  //#region src/grade.ts
89
- /** Reliability thresholds on tool error rate (fraction). */
90
- const RELIABILITY = [
91
- ["A", .05],
92
- ["B", .1],
93
- ["C", .2],
94
- ["D", .35]
95
- ];
96
- /** Re-ask thresholds: corrections per 10 user turns. */
97
- const REASK = [
98
- ["A", 0],
99
- ["B", 1],
100
- ["C", 2],
101
- ["D", 4]
102
- ];
103
- /** Tool-load thresholds: tool calls per user turn (both churn extremes graded). */
104
- const TOOL_LOAD_LOW_B = .5;
105
- const TOOL_LOAD_LOW_C = .1;
106
- const TOOL_LOAD_HIGH_C = 8;
107
- const TOOL_LOAD_HIGH_D = 20;
108
- function gradeBy(rate, table) {
109
- 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];
110
118
  return "E";
111
119
  }
112
120
  const ORDER = [
@@ -122,8 +130,15 @@ function worst(a, b) {
122
130
  function pct(rate) {
123
131
  return `${(rate * 100).toFixed(1)}%`;
124
132
  }
125
- /** Grade one session deterministically. */
126
- 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
+ };
127
142
  const dimensions = [];
128
143
  const notes = [];
129
144
  if (m.toolCalls === 0) {
@@ -134,7 +149,7 @@ function gradeSession(m) {
134
149
  });
135
150
  notes.push("Reliability is n/a — the session made no tool calls.");
136
151
  } else {
137
- const grade = gradeBy(m.toolErrorRate, RELIABILITY);
152
+ const grade = gradeBy(m.toolErrorRate, b.reliability);
138
153
  dimensions.push({
139
154
  dimension: "Reliability",
140
155
  grade,
@@ -147,7 +162,7 @@ function gradeSession(m) {
147
162
  detail: "no user turns"
148
163
  });
149
164
  else {
150
- const grade = gradeBy(m.correctionRate, REASK);
165
+ const grade = gradeBy(m.correctionRate, b.reask);
151
166
  dimensions.push({
152
167
  dimension: "Re-ask",
153
168
  grade,
@@ -160,9 +175,10 @@ function gradeSession(m) {
160
175
  detail: "no tool activity"
161
176
  });
162
177
  else {
178
+ const [lowC, lowB, highC, highD] = b.toolLoad;
163
179
  let grade = "A";
164
- if (m.toolLoad < TOOL_LOAD_LOW_B) grade = m.toolLoad < TOOL_LOAD_LOW_C ? "C" : "B";
165
- 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");
166
182
  dimensions.push({
167
183
  dimension: "Tool load",
168
184
  grade,
@@ -229,7 +245,7 @@ function compareSessions(before, after) {
229
245
  *
230
246
  * @module dsh-session-eval/version
231
247
  */
232
- const VERSION = "0.2.4";
248
+ const VERSION = "0.3.0";
233
249
  //#endregion
234
250
  //#region src/evalCommand.ts
235
251
  const EVAL_USAGE = "Usage: /eval [--id <sessionId>] [--json]";
@@ -312,7 +328,7 @@ async function readEvents(ctx, seam, sessionIdRaw) {
312
328
  }
313
329
  }
314
330
  /** Execute /eval against the session-query seam. */
315
- async function executeEval(ctx, invocation, seam = ctx.sessionQuery) {
331
+ async function executeEval(ctx, invocation, seam = ctx.sessionQuery, bands = {}) {
316
332
  const parsed = parseEvalArgs(invocation.rawInput);
317
333
  if (typeof parsed === "string") return {
318
334
  kind: "error",
@@ -325,18 +341,18 @@ async function executeEval(ctx, invocation, seam = ctx.sessionQuery) {
325
341
  text: events
326
342
  };
327
343
  const metrics = computeMetrics(sessionIdRaw, adaptEvents(events));
328
- const card = gradeSession(metrics);
344
+ const card = gradeSession(metrics, bands);
329
345
  if (parsed.json === true) return {
330
346
  kind: "success",
331
347
  text: JSON.stringify({
332
- generator: "dsh-session-eval v0.2.4",
348
+ generator: "dsh-session-eval v0.3.0",
333
349
  metrics,
334
350
  card
335
351
  }, null, 2)
336
352
  };
337
353
  return {
338
354
  kind: "success",
339
- text: renderCard(card, "dsh-session-eval v0.2.4")
355
+ text: renderCard(card, "dsh-session-eval v0.3.0")
340
356
  };
341
357
  }
342
358
  /** Execute /eval-diff against the session-query seam. */
@@ -360,13 +376,13 @@ async function executeEvalDiff(ctx, invocation, seam = ctx.sessionQuery) {
360
376
  if (parsed.json === true) return {
361
377
  kind: "success",
362
378
  text: JSON.stringify({
363
- generator: "dsh-session-eval v0.2.4",
379
+ generator: "dsh-session-eval v0.3.0",
364
380
  report
365
381
  }, null, 2)
366
382
  };
367
383
  return {
368
384
  kind: "success",
369
- text: renderTrend(report, "dsh-session-eval v0.2.4")
385
+ text: renderTrend(report, "dsh-session-eval v0.3.0")
370
386
  };
371
387
  }
372
388
  //#endregion
@@ -381,7 +397,7 @@ async function executeEvalDiff(ctx, invocation, seam = ctx.sessionQuery) {
381
397
  *
382
398
  * @module dsh-session-eval/historyCommand
383
399
  */
384
- const EVAL_HISTORY_USAGE = "Usage: /eval-history [N] [--json] [--out PATH] (N = last N sessions, 1-20, default 5)";
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)";
385
401
  /** Parse /eval-history input; returns args or a usage-error string. */
386
402
  function parseEvalHistoryArgs(rawInput) {
387
403
  const tokens = rawInput.trim().split(/\s+/).filter((t) => t.length > 0);
@@ -389,8 +405,22 @@ function parseEvalHistoryArgs(rawInput) {
389
405
  let json = false;
390
406
  let sawCount = false;
391
407
  let out;
408
+ let since;
392
409
  for (let i = 0; i < tokens.length; i++) {
393
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
+ }
394
424
  if (token === "--out") {
395
425
  if (out !== void 0) return `--out may be given only once.\n${EVAL_HISTORY_USAGE}`;
396
426
  const path = tokens[++i];
@@ -414,7 +444,8 @@ function parseEvalHistoryArgs(rawInput) {
414
444
  return {
415
445
  count,
416
446
  json,
417
- ...out !== void 0 ? { out } : {}
447
+ ...out !== void 0 ? { out } : {},
448
+ ...since !== void 0 ? { since } : {}
418
449
  };
419
450
  }
420
451
  function toMillis(createdAt) {
@@ -432,7 +463,10 @@ function stamp(ms) {
432
463
  /** Render a history report as terminal text. */
433
464
  function renderHistory(report) {
434
465
  const lines = [];
435
- 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
+ }
436
470
  lines.push(`Session history — last ${report.entries.length} in this workspace`);
437
471
  for (const entry of report.entries) {
438
472
  const m = entry.metrics;
@@ -443,7 +477,7 @@ function renderHistory(report) {
443
477
  return lines.join("\n");
444
478
  }
445
479
  /** Execute /eval-history against the session-query seam. */
446
- async function executeEvalHistory(ctx, invocation, seam = ctx.sessionQuery) {
480
+ async function executeEvalHistory(ctx, invocation, seam = ctx.sessionQuery, bands = {}) {
447
481
  const parsed = parseEvalHistoryArgs(invocation.rawInput);
448
482
  if (typeof parsed === "string") return {
449
483
  kind: "error",
@@ -459,14 +493,37 @@ async function executeEvalHistory(ctx, invocation, seam = ctx.sessionQuery) {
459
493
  text: `Could not list sessions: ${error instanceof Error ? error.message : String(error)}`
460
494
  };
461
495
  }
462
- 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) => ({
463
497
  id: String(header.id),
464
498
  createdAt: toMillis(header.createdAt)
465
- })).sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)).slice(-parsed.count);
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
+ }
466
521
  const entries = [];
467
522
  for (const header of recent) {
523
+ const cached = activityCache.get(header.id);
468
524
  let events;
469
- try {
525
+ if (cached !== void 0) events = cached;
526
+ else try {
470
527
  events = (await seam.readSession(SessionId(header.id))).events;
471
528
  } catch (error) {
472
529
  return {
@@ -479,12 +536,13 @@ async function executeEvalHistory(ctx, invocation, seam = ctx.sessionQuery) {
479
536
  sessionId: header.id,
480
537
  createdAt: header.createdAt,
481
538
  metrics,
482
- card: gradeSession(metrics)
539
+ card: gradeSession(metrics, bands)
483
540
  });
484
541
  }
485
542
  const report = {
486
543
  entries,
487
- 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 } : {}
488
546
  };
489
547
  const text = parsed.json ? JSON.stringify({
490
548
  generator: `dsh-session-eval v${VERSION}`,
@@ -508,16 +566,89 @@ async function executeEvalHistory(ctx, invocation, seam = ctx.sessionQuery) {
508
566
  };
509
567
  }
510
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];
628
+ return {
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
632
+ };
633
+ }
634
+ //#endregion
511
635
  //#region src/index.ts
512
636
  const name = "session-eval";
513
637
  const inject = ["commands", "sessionQuery"];
514
- /** Plugin entry: mount the /eval, /eval-diff and /eval-history commands. */
515
- 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);
516
647
  ctx.effect(function* () {
517
648
  yield ctx.commands.register({
518
649
  name: "eval",
519
650
  description: "Print a deterministic grade card for this session (or another via --id): reliability, re-ask, tool load",
520
- handler: (invocation) => executeEval(ctx, invocation)
651
+ handler: (invocation) => executeEval(ctx, invocation, void 0, bands)
521
652
  });
522
653
  yield ctx.commands.register({
523
654
  name: "eval-diff",
@@ -527,7 +658,7 @@ function apply(ctx) {
527
658
  yield ctx.commands.register({
528
659
  name: "eval-history",
529
660
  description: "Grade the last N sessions in this workspace (default 5) and show whether the trend is improving",
530
- handler: (invocation) => executeEvalHistory(ctx, invocation)
661
+ handler: (invocation) => executeEvalHistory(ctx, invocation, void 0, bands)
531
662
  });
532
663
  }, "session-eval lifecycle");
533
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.4",
4
+ "version": "0.3.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },