dsh-hooks 0.11.0 → 0.12.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/README.md CHANGED
@@ -107,6 +107,7 @@ Every hook field:
107
107
  | `agent/error` | The agent loop reports an error | error text |
108
108
  | `agent/status` | Agent status transition | status |
109
109
  | `hook/failed` | A hook fails consecutively past `failedAlertThreshold` (default 3; synthetic, emitted from the outcome stream) | failing hook summary, consecutive failure count |
110
+ | `usage/daily` | The first event after the local calendar day rolls over (synthetic, no timers): reports the token usage of the day that just ended | covered day, turns that day, contributing sessions, day's token totals |
110
111
 
111
112
  The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `error`, …). Hooks for other events run unconditionally.
112
113
 
@@ -134,11 +135,11 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
134
135
  | `DSH_HOOK_STATUS` | agent status (`agent/status`) |
135
136
  | `DSH_HOOK_ERROR` | error text (`agent/error`, and the failure message on `turn/end` error) |
136
137
  | `DSH_HOOK_CONTENT` | event content snapshot: turn assistant text, tool result text, user message text, turn-initiating message text (turn/start) |
137
- | `DSH_HOOK_USAGE_INPUT_TOKENS` | aggregated input tokens of the turn (turn/end, summed across steps) |
138
- | `DSH_HOOK_USAGE_OUTPUT_TOKENS` | aggregated output tokens of the turn |
139
- | `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | aggregated cache-read tokens, when reported |
140
- | `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | aggregated cache-write tokens, when reported |
141
- | `DSH_HOOK_USAGE_REASONING_TOKENS` | aggregated reasoning tokens, when reported |
138
+ | `DSH_HOOK_USAGE_INPUT_TOKENS` | input token total (turn/end: this turn, summed across steps; usage/daily: the whole day) |
139
+ | `DSH_HOOK_USAGE_OUTPUT_TOKENS` | output token total (same scoping) |
140
+ | `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | cache-read tokens when reported (same scoping) |
141
+ | `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | cache-write tokens when reported (same scoping) |
142
+ | `DSH_HOOK_USAGE_REASONING_TOKENS` | reasoning tokens when reported (same scoping) |
142
143
  | `DSH_HOOK_RUNNING_SUBAGENTS` | live subagents still running under this session (turn/end; `0` = none — lets a hook tell "work handed off to background subagents" apart from "the turn finished for real") |
143
144
  | `DSH_HOOK_PARENT_SESSION_ID` | parent session id (subagent lineage; absent for top-level sessions) |
144
145
  | `DSH_HOOK_SUBAGENT` | `1` when the session is a subagent child, `0` otherwise |
@@ -151,6 +152,9 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
151
152
  | `DSH_HOOK_TREE_DURATION_MS` | parent turn/end → tree settle duration, ms (`tree/settled`) |
152
153
  | `DSH_HOOK_FAILED_HOOK` | identity summary of the hook that failed consecutively (`hook/failed`) |
153
154
  | `DSH_HOOK_FAILURES` | consecutive failure count when the alert fired (`hook/failed`) |
155
+ | `DSH_HOOK_USAGE_DAY` | local calendar day the token totals cover, `YYYY-MM-DD` (`usage/daily`) |
156
+ | `DSH_HOOK_USAGE_TURNS` | turns with reported accounting that day (`usage/daily`) |
157
+ | `DSH_HOOK_USAGE_SESSIONS` | distinct sessions that contributed usage that day (`usage/daily`) |
154
158
  | `DSH_HOOK_TIMESTAMP` | ISO timestamp |
155
159
 
156
160
  - `{{var}}` placeholders inside `run` are substituted from the same context, e.g. `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`.
@@ -184,6 +188,26 @@ For the simpler "notify only once the whole tree settles" pattern, the synthetic
184
188
 
185
189
  Settled-but-idle continuable children do not count as running, so they don't keep suppressing the notification. The settle watch is event-driven and best-effort: it survives until the plugin restarts, and a failed re-check drops the watch silently (no late notification).
186
190
 
191
+ ### usage/daily: the cross-day token report
192
+
193
+ `turn/end` answers "what did this turn cost". For a per-day view, use the synthetic `usage/daily` event: the plugin accumulates every reported `turn/end` usage in memory per **local calendar day** (subagent sessions included — same account), and when the day rolls over it emits one report for the day that just ended, on the next event that arrives. Detection is purely event-driven: no timers, no scheduled tasks.
194
+
195
+ ```yaml
196
+ - on: 'usage/daily'
197
+ match: { usageInputTokens: '>0' } # optional: skip days without usage
198
+ run: 'node examples/log-usage.mjs' # or notify: { channel: 'webhook', url: '…' }
199
+ ```
200
+
201
+ `DSH_HOOK_USAGE_DAY` is the day the report covers (`YYYY-MM-DD`); `DSH_HOOK_USAGE_TURNS` / `DSH_HOOK_USAGE_SESSIONS` are that day's counted turns and contributing sessions; the token details reuse the `turn/end` variable names (`usageInputTokens` / `usageOutputTokens` / `usageCacheReadTokens` / `usageCacheWriteTokens` / `usageReasoningTokens`) with day scope instead of turn scope.
202
+
203
+ Three boundaries by design, not bugs:
204
+
205
+ - **In-memory**: a plugin-process restart drops the day in progress (the new process starts a fresh day at zero); reports already emitted are unaffected.
206
+ - **Event-driven, not timed**: a day is reported when the next event arrives, so after a quiet midnight the report waits for the next event; a day with no reported turn usage is never reported (an empty report is noise).
207
+ - **Zero cost when unused**: with no `usage/daily` hook declared, no accumulation and no day check happen at all.
208
+
209
+ `dsh-hooks dry-run usage/daily` simulates a report for "yesterday" with non-zero tokens, so match filters and the command can be verified first.
210
+
187
211
  ### Numeric match comparisons
188
212
 
189
213
  Numeric context fields (`turn`, `step`, `durationMs`, `toolDurationMs`, `usage*`, `runningSubagents`, …) support real comparisons instead of regex hacks:
package/README.zh.md CHANGED
@@ -107,6 +107,7 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
107
107
  | `agent/error` | Agent 循环报错 | 错误文本 |
108
108
  | `agent/status` | Agent 状态切换 | 状态 |
109
109
  | `hook/failed` | 同一 hook 连续失败达到 `failedAlertThreshold`(默认 3;合成事件,从结果流发射) | 失败 hook 摘要、连续失败次数 |
110
+ | `usage/daily` | 本地日历日翻篇后的下一个事件(合成事件,无定时器):报告刚结束那一天的 token 用量 | 覆盖日期、当日回合数、贡献会话数、当日 token 明细 |
110
111
 
111
112
  `turn/end` 的 `when` 匹配结束原因(`completed`、`error`…);其他事件的 hook 无条件执行。
112
113
 
@@ -134,11 +135,11 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
134
135
  | `DSH_HOOK_STATUS` | Agent 状态(agent/status) |
135
136
  | `DSH_HOOK_ERROR` | 错误文本(agent/error,以及 turn/end 出错时的失败详情) |
136
137
  | `DSH_HOOK_CONTENT` | 事件内容快照:回合最后助手文本、工具结果文本、用户消息文本、回合触发消息文本(turn/start) |
137
- | `DSH_HOOK_USAGE_INPUT_TOKENS` | 本回合输入 token 总量(turn/end,逐 step 聚合) |
138
- | `DSH_HOOK_USAGE_OUTPUT_TOKENS` | 本回合输出 token 总量 |
139
- | `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | 本回合缓存读 token(有上报时) |
140
- | `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | 本回合缓存写 token(有上报时) |
141
- | `DSH_HOOK_USAGE_REASONING_TOKENS` | 本回合思考 token(有上报时) |
138
+ | `DSH_HOOK_USAGE_INPUT_TOKENS` | 输入 token 总量(turn/end 为本回合、逐 step 聚合;usage/daily 为当日聚合) |
139
+ | `DSH_HOOK_USAGE_OUTPUT_TOKENS` | 输出 token 总量(同上) |
140
+ | `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | 缓存读 token(有上报时,同上) |
141
+ | `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | 缓存写 token(有上报时,同上) |
142
+ | `DSH_HOOK_USAGE_REASONING_TOKENS` | 思考 token(有上报时,同上) |
142
143
  | `DSH_HOOK_RUNNING_SUBAGENTS` | 本会话下仍在运行的存活子代理数(turn/end;`0` = 无——让 hook 能区分「工作已交给后台子代理」与「回合真正结束」) |
143
144
  | `DSH_HOOK_PARENT_SESSION_ID` | 父会话 id(子代理谱系;顶层会话无此变量) |
144
145
  | `DSH_HOOK_SUBAGENT` | 会话为子代理时为 `1`,否则 `0` |
@@ -151,6 +152,9 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
151
152
  | `DSH_HOOK_TREE_DURATION_MS` | 父回合结束 → 树落定的耗时(毫秒,`tree/settled`) |
152
153
  | `DSH_HOOK_FAILED_HOOK` | 连续失败的 hook 身份摘要(`hook/failed`) |
153
154
  | `DSH_HOOK_FAILURES` | 告警触发时的连续失败次数(`hook/failed`) |
155
+ | `DSH_HOOK_USAGE_DAY` | 报告覆盖的本地日历日 `YYYY-MM-DD`(`usage/daily`) |
156
+ | `DSH_HOOK_USAGE_TURNS` | 当日计入的回合数(`usage/daily`) |
157
+ | `DSH_HOOK_USAGE_SESSIONS` | 当日贡献用量的会话数(`usage/daily`) |
154
158
  | `DSH_HOOK_TIMESTAMP` | ISO 时间戳 |
155
159
 
156
160
  - `run` 里的 `{{变量}}` 占位符会从同一上下文替换,例如 `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`。
@@ -185,6 +189,26 @@ config:
185
189
 
186
190
  已落定但闲置(idle)的 continuable 子代理不计入运行中,不会一直压住通知。落定监视是事件驱动且 best-effort 的:插件重启后监视集合丢失;重查失败会静默放弃该监视(不会补发迟到的通知)。
187
191
 
192
+ ### usage/daily:跨日 token 日报
193
+
194
+ `turn/end` 只回答「这个回合花了多少」。要按天看成本,用合成事件 `usage/daily`:插件在内存里按**本地日历日**累计每个 `turn/end` 上报的 token(子代理会话的回合一并计入——同一个账号),日期翻篇后对下一个到达的事件发射一次日报,报告刚结束的那一天。检测纯事件驱动、无定时器、无定时任务。
195
+
196
+ ```yaml
197
+ - on: 'usage/daily'
198
+ match: { usageInputTokens: '>0' } # 可选:跳过没有用量的日子
199
+ run: 'node examples/log-usage.mjs' # 或 notify: { channel: 'webhook', url: '…' }
200
+ ```
201
+
202
+ `DSH_HOOK_USAGE_DAY` 是报告覆盖的日期(`YYYY-MM-DD`);`DSH_HOOK_USAGE_TURNS` / `DSH_HOOK_USAGE_SESSIONS` 是当日计入的回合数与贡献会话数;token 明细沿用 `turn/end` 的 `DSH_HOOK_USAGE_*` 变量名(`usageInputTokens` / `usageOutputTokens` / `usageCacheReadTokens` / `usageCacheWriteTokens` / `usageReasoningTokens`),语义变为「该日聚合」。
203
+
204
+ 三条边界(按设计,不是 bug):
205
+
206
+ - **内存累计**:插件进程重启会丢掉进行中那一天的累计(重启后从新的一天、从零开始);已发出的日报不受影响。
207
+ - **事件驱动而非定时**:一天的用量要等下一个事件到达才报告,所以跨夜后若一直没动静,日报会推迟到下一次有事件时补发;那一天从未有回合上报用量则不发射(空日报是噪声)。
208
+ - **零开销**:没有声明任何 `usage/daily` hook 时,插件完全不做累计与跨日检测。
209
+
210
+ `dsh-hooks dry-run usage/daily` 用「昨天」和非零 token 模拟一次日报,可先验证 match 与命令。
211
+
188
212
  ### match 数值比较
189
213
 
190
214
  对数字字段(`turn`、`step`、`durationMs`、`toolDurationMs`、`usage*`、`runningSubagents`…)可以直接写数值比较,不用绕正则:
package/lib/client.js CHANGED
@@ -200,18 +200,22 @@ window.__ModuleLoader__.load({
200
200
  const EVENTS = [
201
201
  "turn/start",
202
202
  "turn/end",
203
+ "tree/settled",
203
204
  "step/end",
204
205
  "tool/call",
205
206
  "tool/result",
206
207
  "user/message",
207
208
  "approval/asked",
209
+ "approval/decided",
208
210
  "session/title",
209
211
  "session/created",
210
212
  "session/disposed",
211
213
  "agent/created",
212
214
  "agent/disposed",
213
215
  "agent/error",
214
- "agent/status"
216
+ "agent/status",
217
+ "hook/failed",
218
+ "usage/daily"
215
219
  ];
216
220
  const TURN_END_REASONS = [
217
221
  "completed",
package/lib/config.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Hookable event kinds. v1 is emit-only: no waterfall/interception events. */
2
- export declare const HOOK_EVENTS: readonly ['turn/start', 'turn/end', 'tree/settled', 'step/end', 'tool/call', 'tool/result', 'user/message', 'approval/asked', 'approval/decided', 'session/title', 'session/created', 'session/disposed', 'agent/created', 'agent/disposed', 'agent/error', 'agent/status', 'hook/failed'];
2
+ export declare const HOOK_EVENTS: readonly ['turn/start', 'turn/end', 'tree/settled', 'step/end', 'tool/call', 'tool/result', 'user/message', 'approval/asked', 'approval/decided', 'session/title', 'session/created', 'session/disposed', 'agent/created', 'agent/disposed', 'agent/error', 'agent/status', 'hook/failed', 'usage/daily'];
3
3
  export type HookEvent = (typeof HOOK_EVENTS)[number];
4
4
  /** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
5
5
  export declare const TURN_END_REASONS: readonly ['completed', 'error', 'aborted', 'blocked', 'max-tokens', 'interrupted'];
package/lib/config.js CHANGED
@@ -18,6 +18,7 @@ export const HOOK_EVENTS = [
18
18
  'agent/error',
19
19
  'agent/status',
20
20
  'hook/failed',
21
+ 'usage/daily',
21
22
  ];
22
23
  /** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
23
24
  export const TURN_END_REASONS = [
@@ -34,7 +35,7 @@ export const TURN_END_REASONS = [
34
35
  // declaration self-contained.
35
36
  export const Config = Schema.object({
36
37
  hooks: Schema.array(Schema.object({
37
- on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | tree/settled | step/end | tool/call | tool/result | user/message | approval/asked | approval/decided | session/title | session/created | session/disposed | agent/created | agent/disposed | agent/error | agent/status | hook/failed'),
38
+ on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | tree/settled | step/end | tool/call | tool/result | user/message | approval/asked | approval/decided | session/title | session/created | session/disposed | agent/created | agent/disposed | agent/error | agent/status | hook/failed | usage/daily'),
38
39
  when: Schema.union([...TURN_END_REASONS]).description('可选过滤:对 turn/end 匹配结束原因(completed/error/aborted/blocked/max-tokens/interrupted);其他事件忽略该字段'),
39
40
  match: Schema.dict(Schema.union([
40
41
  Schema.regExp(),
package/lib/context.d.ts CHANGED
@@ -35,7 +35,7 @@ export interface HookContext {
35
35
  * pairing tool/call was never seen, e.g. after a plugin restart).
36
36
  */
37
37
  toolDurationMs?: number;
38
- /** Aggregated token usage of the turn (turn/end), when reported. */
38
+ /** Aggregated token usage of the turn (turn/end), or of the day (usage/daily). */
39
39
  usageInputTokens?: number;
40
40
  usageOutputTokens?: number;
41
41
  usageCacheReadTokens?: number;
@@ -69,6 +69,15 @@ export interface HookContext {
69
69
  hookFailedHook?: string;
70
70
  /** Consecutive failure count when the alert fired (hook/failed). */
71
71
  hookFailures?: number;
72
+ /**
73
+ * Local calendar day (`YYYY-MM-DD`) the token totals below cover
74
+ * (usage/daily only; turn/end carries a single turn, not a day).
75
+ */
76
+ usageDay?: string;
77
+ /** Turns with reported accounting that day (usage/daily). */
78
+ usageTurns?: number;
79
+ /** Distinct sessions that contributed usage that day (usage/daily). */
80
+ usageSessions?: number;
72
81
  timestamp: string;
73
82
  }
74
83
  export declare function toEnv(ctx: HookContext): Record<string, string>;
package/lib/context.js CHANGED
@@ -71,6 +71,12 @@ export function toEnv(ctx) {
71
71
  env.DSH_HOOK_FAILED_HOOK = ctx.hookFailedHook;
72
72
  if (ctx.hookFailures !== undefined)
73
73
  env.DSH_HOOK_FAILURES = String(ctx.hookFailures);
74
+ if (ctx.usageDay !== undefined)
75
+ env.DSH_HOOK_USAGE_DAY = ctx.usageDay;
76
+ if (ctx.usageTurns !== undefined)
77
+ env.DSH_HOOK_USAGE_TURNS = String(ctx.usageTurns);
78
+ if (ctx.usageSessions !== undefined)
79
+ env.DSH_HOOK_USAGE_SESSIONS = String(ctx.usageSessions);
74
80
  return env;
75
81
  }
76
82
  /** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
package/lib/dry-run.js CHANGED
@@ -9,6 +9,7 @@ import { join } from 'node:path';
9
9
  import YAML from 'yaml';
10
10
  import { Config } from './config.js';
11
11
  import { matchFilters } from './events.js';
12
+ import { localDayKey } from './usage.js';
12
13
  import { createHookRunner } from './runner.js';
13
14
  import { fireNotify } from './notify.js';
14
15
  /** Profile patch file for a profile name. */
@@ -48,7 +49,7 @@ export function loadHooks(profile, paths = {}) {
48
49
  }
49
50
  /** A synthetic context for the simulated event, overridable per field. */
50
51
  export function mockContext(event, overrides = {}) {
51
- return {
52
+ const ctx = {
52
53
  event,
53
54
  sessionId: 'dry-run',
54
55
  sessionName: 'dry-run 会话',
@@ -59,8 +60,21 @@ export function mockContext(event, overrides = {}) {
59
60
  callId: 'dry-run-call',
60
61
  content: 'dry-run 模拟内容',
61
62
  timestamp: new Date().toISOString(),
62
- ...overrides,
63
63
  };
64
+ if (event === 'usage/daily') {
65
+ // A daily report always describes a day that already ended, and the
66
+ // simulated numbers must be non-zero so `match` filters on them (e.g.
67
+ // `{ usageInputTokens: '>0' }`) are actually exercisable.
68
+ ctx.usageDay = localDayKey(new Date(Date.now() - 86_400_000));
69
+ ctx.usageTurns = 12;
70
+ ctx.usageSessions = 3;
71
+ ctx.usageInputTokens = 120_000;
72
+ ctx.usageOutputTokens = 45_000;
73
+ ctx.usageCacheReadTokens = 90_000;
74
+ ctx.usageCacheWriteTokens = 6_000;
75
+ ctx.usageReasoningTokens = 8_000;
76
+ }
77
+ return { ...ctx, ...overrides };
64
78
  }
65
79
  /** Render a match value (regex source, comparison op, or object form). */
66
80
  function matchText(value) {
package/lib/events.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session';
2
2
  import type { HookContext } from './context.js';
3
3
  import type { HookSpec, NumericMatch, TurnEndReasonKind } from './config.js';
4
+ import type { DailyUsageTotals, UsageTotals } from './usage.js';
4
5
  import type { AgentLike } from './types.js';
6
+ export type { UsageTotals } from './usage.js';
5
7
  /** `approval/asked` payload (merge-extensible, declared by dsh-user-approval). */
6
8
  export interface ApprovalAskedData {
7
9
  id: string;
@@ -65,14 +67,6 @@ export declare function sessionTitle(session: Session): string | undefined;
65
67
  * their own display truncation.
66
68
  */
67
69
  export declare function turnContent(session: Session, turn: number): string | undefined;
68
- /** Aggregated turn usage for hook contexts (only fields actually reported). */
69
- export interface UsageTotals {
70
- inputTokens: number;
71
- outputTokens: number;
72
- cacheReadTokens?: number;
73
- cacheWriteTokens?: number;
74
- reasoningTokens?: number;
75
- }
76
70
  /**
77
71
  * Sum the `usage` of every `assistant/message` of a turn. Steps without
78
72
  * reported accounting are skipped; returns undefined when no step reported
@@ -128,6 +122,16 @@ export declare function treeSettledContext(session: Session, totalSubagents: num
128
122
  * session identity of the event that triggered the failing hook.
129
123
  */
130
124
  export declare function hookFailedContext(origin: HookContext, hookFailedHook: string, hookFailures: number): HookContext;
125
+ /**
126
+ * Synthetic `usage/daily` context: the local calendar day that just ended,
127
+ * with its aggregated token usage. Emitted by index.ts when the day rolls
128
+ * over (detected from ordinary event traffic — no timers); `origin` supplies
129
+ * the session identity of the event that triggered the report.
130
+ *
131
+ * The token fields reuse the `turn/end` names on purpose: a hook reads the
132
+ * same variables, with the day's aggregate instead of one turn's.
133
+ */
134
+ export declare function usageDailyContext(origin: HookContext, totals: DailyUsageTotals): HookContext;
131
135
  export declare function agentCreatedContext(agent: AgentLike): HookContext;
132
136
  export declare function agentDisposedContext(agent: AgentLike): HookContext;
133
137
  export declare function agentErrorContext(agent: AgentLike, turn: number | undefined, error: unknown): HookContext;
package/lib/events.js CHANGED
@@ -403,6 +403,34 @@ export function hookFailedContext(origin, hookFailedHook, hookFailures) {
403
403
  timestamp: new Date().toISOString(),
404
404
  };
405
405
  }
406
+ /**
407
+ * Synthetic `usage/daily` context: the local calendar day that just ended,
408
+ * with its aggregated token usage. Emitted by index.ts when the day rolls
409
+ * over (detected from ordinary event traffic — no timers); `origin` supplies
410
+ * the session identity of the event that triggered the report.
411
+ *
412
+ * The token fields reuse the `turn/end` names on purpose: a hook reads the
413
+ * same variables, with the day's aggregate instead of one turn's.
414
+ */
415
+ export function usageDailyContext(origin, totals) {
416
+ return {
417
+ event: 'usage/daily',
418
+ sessionId: origin.sessionId,
419
+ sessionName: origin.sessionName,
420
+ cwd: origin.cwd,
421
+ usageDay: totals.day,
422
+ usageTurns: totals.turns,
423
+ usageSessions: totals.sessions,
424
+ usageInputTokens: totals.inputTokens,
425
+ usageOutputTokens: totals.outputTokens,
426
+ // Only fields some turn actually reported: an absent variable is easier to
427
+ // reason about (and to match on) than one that is present-but-undefined.
428
+ ...(totals.cacheReadTokens !== undefined ? { usageCacheReadTokens: totals.cacheReadTokens } : {}),
429
+ ...(totals.cacheWriteTokens !== undefined ? { usageCacheWriteTokens: totals.cacheWriteTokens } : {}),
430
+ ...(totals.reasoningTokens !== undefined ? { usageReasoningTokens: totals.reasoningTokens } : {}),
431
+ timestamp: new Date().toISOString(),
432
+ };
433
+ }
406
434
  export function agentCreatedContext(agent) {
407
435
  return {
408
436
  event: 'agent/created',
package/lib/index.d.ts CHANGED
@@ -59,7 +59,7 @@ export { createHistorySink } from './history.js';
59
59
  * Model-facing announcement, installed only when the system-prompt service
60
60
  * exists (web profile). Tells agents the plugin exists and how to cooperate.
61
61
  */
62
- export declare const DSH_HOOKS_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-hooks \u63D2\u4EF6\uFF08DeepSeek Harness \u914D\u7F6E\u9A71\u52A8\u751F\u547D\u5468\u671F hooks\uFF09\uFF1A\u53EF\u5728 profile \u7684 cordis.patch.yml \u58F0\u660E\u300C\u4E8B\u4EF6 \u2192 \u547D\u4EE4/\u901A\u77E5\u300D\u7684 hook\uFF08turn/start\u3001turn/end\u3001tree/settled\u3001step/end\u3001tool/call\u3001tool/result\u3001user/message\u3001approval/asked\u3001approval/decided\u3001session/title\u3001session/created\u3001session/disposed\u3001agent/created\u3001agent/disposed\u3001agent/error\u3001agent/status\u3001hook/failed \u5171 17 \u7C7B\u4E8B\u4EF6\uFF09\uFF0C\u652F\u6301 when \u539F\u56E0\u8FC7\u6EE4\u3001match \u5B57\u6BB5\u6B63\u5219/\u6570\u503C\u6BD4\u8F83\u8FC7\u6EE4\uFF08\u5982 '>10000'\uFF09\u3001stdin JSON \u8F93\u5165\u3001opt-in \u91CD\u8BD5\u3001\u6267\u884C\u9009\u9879\uFF08enabled \u505C\u7528 / cwd \u5DE5\u4F5C\u76EE\u5F55 / maxConcurrent + debounceMs \u9632\u9AD8\u9891\u98CE\u66B4\uFF09\u3001\u5185\u7F6E webhook/desktop \u901A\u77E5\u6E20\u9053\uFF1B\u6267\u884C\u5386\u53F2\u8BB0\u5F55\u4E8E ~/.dsh/dsh-hooks/history.jsonl\uFF1B`dsh-hooks dry-run <event>` \u53EF\u6A21\u62DF\u4E8B\u4EF6\u9A8C\u8BC1\u914D\u7F6E\u3002\u7528\u6237\u63D0\u5230\u300Chooks / \u94A9\u5B50 / \u751F\u547D\u5468\u671F / \u901A\u77E5\u914D\u7F6E\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
62
+ export declare const DSH_HOOKS_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-hooks \u63D2\u4EF6\uFF08DeepSeek Harness \u914D\u7F6E\u9A71\u52A8\u751F\u547D\u5468\u671F hooks\uFF09\uFF1A\u53EF\u5728 profile \u7684 cordis.patch.yml \u58F0\u660E\u300C\u4E8B\u4EF6 \u2192 \u547D\u4EE4/\u901A\u77E5\u300D\u7684 hook\uFF08turn/start\u3001turn/end\u3001tree/settled\u3001step/end\u3001tool/call\u3001tool/result\u3001user/message\u3001approval/asked\u3001approval/decided\u3001session/title\u3001session/created\u3001session/disposed\u3001agent/created\u3001agent/disposed\u3001agent/error\u3001agent/status\u3001hook/failed\u3001usage/daily \u5171 18 \u7C7B\u4E8B\u4EF6\uFF09\uFF0C\u652F\u6301 when \u539F\u56E0\u8FC7\u6EE4\u3001match \u5B57\u6BB5\u6B63\u5219/\u6570\u503C\u6BD4\u8F83\u8FC7\u6EE4\uFF08\u5982 '>10000'\uFF09\u3001stdin JSON \u8F93\u5165\u3001opt-in \u91CD\u8BD5\u3001\u6267\u884C\u9009\u9879\uFF08enabled \u505C\u7528 / cwd \u5DE5\u4F5C\u76EE\u5F55 / maxConcurrent + debounceMs \u9632\u9AD8\u9891\u98CE\u66B4\uFF09\u3001\u5185\u7F6E webhook/desktop \u901A\u77E5\u6E20\u9053\uFF1B\u6267\u884C\u5386\u53F2\u8BB0\u5F55\u4E8E ~/.dsh/dsh-hooks/history.jsonl\uFF1B`dsh-hooks dry-run <event>` \u53EF\u6A21\u62DF\u4E8B\u4EF6\u9A8C\u8BC1\u914D\u7F6E\u3002\u7528\u6237\u63D0\u5230\u300Chooks / \u94A9\u5B50 / \u751F\u547D\u5468\u671F / \u901A\u77E5\u914D\u7F6E\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
63
63
  export declare function apply(ctx: Context, config?: Config): void;
64
64
  export declare const _internals: {
65
65
  clearTurnTracking: typeof clearTurnTracking;
package/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import './types.js';
2
2
  import { Config } from './config.js';
3
- import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookFailedContext, hookMatches, matchFilters, sessionCreatedContext, sessionDisposedContext, treeSettledContext, } from './events.js';
3
+ import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookFailedContext, hookMatches, matchFilters, sessionCreatedContext, sessionDisposedContext, treeSettledContext, usageDailyContext, } from './events.js';
4
4
  import { eventLabel } from './context.js';
5
+ import { DailyUsageAccumulator, usageTotalsFromContext } from './usage.js';
5
6
  import { createHookRunner } from './runner.js';
6
7
  import { fireNotify } from './notify.js';
7
8
  import { createHistorySink } from './history.js';
@@ -73,7 +74,7 @@ export { createHistorySink } from './history.js';
73
74
  * Model-facing announcement, installed only when the system-prompt service
74
75
  * exists (web profile). Tells agents the plugin exists and how to cooperate.
75
76
  */
76
- export const DSH_HOOKS_GUIDANCE = '本机已安装 dsh-hooks 插件(DeepSeek Harness 配置驱动生命周期 hooks):可在 profile 的 cordis.patch.yml 声明「事件 → 命令/通知」的 hook(turn/start、turn/end、tree/settled、step/end、tool/call、tool/result、user/message、approval/asked、approval/decided、session/title、session/created、session/disposed、agent/created、agent/disposed、agent/error、agent/status、hook/failed 共 17 类事件),支持 when 原因过滤、match 字段正则/数值比较过滤(如 \'>10000\')、stdin JSON 输入、opt-in 重试、执行选项(enabled 停用 / cwd 工作目录 / maxConcurrent + debounceMs 防高频风暴)、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
77
+ export const DSH_HOOKS_GUIDANCE = '本机已安装 dsh-hooks 插件(DeepSeek Harness 配置驱动生命周期 hooks):可在 profile 的 cordis.patch.yml 声明「事件 → 命令/通知」的 hook(turn/start、turn/end、tree/settled、step/end、tool/call、tool/result、user/message、approval/asked、approval/decided、session/title、session/created、session/disposed、agent/created、agent/disposed、agent/error、agent/status、hook/failed、usage/daily18 类事件),支持 when 原因过滤、match 字段正则/数值比较过滤(如 \'>10000\')、stdin JSON 输入、opt-in 重试、执行选项(enabled 停用 / cwd 工作目录 / maxConcurrent + debounceMs 防高频风暴)、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
77
78
  export function apply(ctx, config = {}) {
78
79
  const hooks = config.hooks ?? [];
79
80
  const history = createHistorySink(config.history ?? undefined);
@@ -184,6 +185,25 @@ export function apply(ctx, config = {}) {
184
185
  };
185
186
  // Per-hook debounce state (trailing timers); cleared on dispose.
186
187
  const debounceTimers = new Map();
188
+ // Synthetic usage/daily: an in-memory per-day token bucket. Bookkeeping is
189
+ // wired only when a usage/daily hook exists — with none declared, no
190
+ // accumulation and no day check happen at all. The day rollover is detected
191
+ // from ordinary event traffic (no timers): the first classified event of a
192
+ // new day reports the day that just ended. Token totals include every
193
+ // session, subagent turns included — they are billed to the same account.
194
+ const hasUsageDailyHooks = hooks.some((hook) => hook.on === 'usage/daily' && hook.enabled !== false);
195
+ const usageDays = new DailyUsageAccumulator();
196
+ /**
197
+ * Feed one classified event to the daily bucket and dispatch the finished
198
+ * day's report when the calendar day rolled over. `origin` supplies the
199
+ * session identity of the triggering event.
200
+ */
201
+ const trackUsageDay = (origin, sessionId) => {
202
+ const totals = origin.event === 'turn/end' ? usageTotalsFromContext(origin) : undefined;
203
+ const finished = usageDays.observe(totals === undefined ? undefined : { totals, sessionId });
204
+ if (finished !== undefined)
205
+ runMatching(usageDailyContext(origin, finished.totals));
206
+ };
187
207
  // turn/start content: the session log records `turn/start` BEFORE the
188
208
  // turn's `user/message`, so the initiating prompt text cannot be read at
189
209
  // turn-start time. When turn/start hooks exist, dispatch is deferred until
@@ -281,6 +301,11 @@ export function apply(ctx, config = {}) {
281
301
  return;
282
302
  const reasonKind = extractReasonKind(event);
283
303
  const sessionId = String(session.id);
304
+ // Day-rollover check runs before any dispatch, so a turn ending just after
305
+ // midnight is reported against the previous day and then bucketed into the
306
+ // new one.
307
+ if (hasUsageDailyHooks)
308
+ trackUsageDay(classified, sessionId);
284
309
  if (classified.event === 'turn/start') {
285
310
  if (!hasTurnStartHooks) {
286
311
  runMatching(classified, reasonKind);
@@ -362,6 +387,7 @@ export function apply(ctx, config = {}) {
362
387
  debounceTimers.clear();
363
388
  pendingTurnStarts.clear();
364
389
  watchedTrees.clear();
390
+ usageDays.reset();
365
391
  });
366
392
  }
367
393
  /** Extract the `turn/end` reason kind from a session event, when present. */
package/lib/server.d.ts CHANGED
@@ -48,7 +48,7 @@ export interface HookRoutesOptions {
48
48
  /** Sanitized per-hook description for the settings panel (regex sources, no RegExp objects). */
49
49
  export declare function describeHooks(hooks: readonly HookSpec[]): {
50
50
  index: number;
51
- on: "agent/created" | "agent/disposed" | "agent/error" | "agent/status" | "approval/asked" | "approval/decided" | "hook/failed" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "tree/settled" | "turn/end" | "turn/start" | "user/message";
51
+ on: "agent/created" | "agent/disposed" | "agent/error" | "agent/status" | "approval/asked" | "approval/decided" | "hook/failed" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "tree/settled" | "turn/end" | "turn/start" | "usage/daily" | "user/message";
52
52
  when: "aborted" | "blocked" | "completed" | "error" | "interrupted" | "max-tokens" | undefined;
53
53
  match: {
54
54
  [k: string]: string;
package/lib/usage.d.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Daily token accounting behind the synthetic `usage/daily` event.
3
+ *
4
+ * The contract is deliberately modest: accumulate in memory, detect the local
5
+ * calendar-day rollover from ordinary event traffic (no timers, no scheduled
6
+ * tasks), and report the day that just ended. Two consequences are documented
7
+ * in the READMEs: a plugin-process restart drops the in-flight day, and a day
8
+ * followed by no further events is reported at the next event rather than at
9
+ * midnight.
10
+ */
11
+ import type { HookContext } from './context.js';
12
+ /** Structural token accounting (disjoint counts; cache fields optional). */
13
+ export interface UsageTotals {
14
+ inputTokens: number;
15
+ outputTokens: number;
16
+ cacheReadTokens?: number;
17
+ cacheWriteTokens?: number;
18
+ reasoningTokens?: number;
19
+ }
20
+ /** One finished day's aggregate, as carried by `usage/daily`. */
21
+ export interface DailyUsageTotals extends UsageTotals {
22
+ /** Local calendar day the totals cover (`YYYY-MM-DD`). */
23
+ day: string;
24
+ /** Turns that reported accounting and contributed to the totals. */
25
+ turns: number;
26
+ /** Distinct sessions that contributed usage that day. */
27
+ sessions: number;
28
+ }
29
+ /** A finished day handed to the caller when the calendar day rolled over. */
30
+ export interface DailyUsageRollover {
31
+ day: string;
32
+ totals: DailyUsageTotals;
33
+ }
34
+ /** One observation fed to the accumulator (a `turn/end` that reported usage). */
35
+ export interface UsageObservation {
36
+ totals: UsageTotals;
37
+ sessionId?: string;
38
+ }
39
+ /**
40
+ * Local calendar day key (`YYYY-MM-DD`). Local — not UTC — because a daily
41
+ * report should follow the machine's day boundary the way the user reads
42
+ * costs; `toISOString` would put the boundary in the wrong place.
43
+ */
44
+ export declare function localDayKey(date?: Date): string;
45
+ /**
46
+ * Read the turn usage already flattened onto a hook context (the same numbers
47
+ * a `turn/end` hook sees, so a `usage/daily` report and the per-turn variables
48
+ * always agree). Returns undefined when the turn reported no accounting.
49
+ */
50
+ export declare function usageTotalsFromContext(ctx: HookContext): UsageTotals | undefined;
51
+ /**
52
+ * In-memory daily usage bucket behind the synthetic `usage/daily` event.
53
+ *
54
+ * `observe` is the single entry point: it rolls the calendar day over first
55
+ * (returning the finished day's report exactly once), then records the
56
+ * observation into the new day. Rolling over *before* recording is what keeps
57
+ * a turn ending just after midnight out of the previous day's totals.
58
+ */
59
+ export declare class DailyUsageAccumulator {
60
+ #private;
61
+ /** The day currently accumulated; `undefined` before the first observation. */
62
+ get day(): string | undefined;
63
+ /**
64
+ * Roll the day over if needed, then record one observation.
65
+ *
66
+ * Returns the finished day's totals when this call crossed a day boundary
67
+ * and that day had reported usage — `undefined` on an ordinary call, on the
68
+ * first observation of a process (nothing accumulated yet), and for a day
69
+ * without usage (an empty report is noise, not a report).
70
+ */
71
+ observe(observation?: UsageObservation, now?: Date): DailyUsageRollover | undefined;
72
+ /**
73
+ * Detect a calendar-day rollover without recording anything, so the first
74
+ * event after midnight can report the day that just ended.
75
+ */
76
+ rollover(now?: Date): DailyUsageRollover | undefined;
77
+ /** Drop all state (plugin dispose). */
78
+ reset(): void;
79
+ }
package/lib/usage.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Local calendar day key (`YYYY-MM-DD`). Local — not UTC — because a daily
3
+ * report should follow the machine's day boundary the way the user reads
4
+ * costs; `toISOString` would put the boundary in the wrong place.
5
+ */
6
+ export function localDayKey(date = new Date()) {
7
+ const month = String(date.getMonth() + 1).padStart(2, '0');
8
+ const day = String(date.getDate()).padStart(2, '0');
9
+ return `${date.getFullYear()}-${month}-${day}`;
10
+ }
11
+ /**
12
+ * Read the turn usage already flattened onto a hook context (the same numbers
13
+ * a `turn/end` hook sees, so a `usage/daily` report and the per-turn variables
14
+ * always agree). Returns undefined when the turn reported no accounting.
15
+ */
16
+ export function usageTotalsFromContext(ctx) {
17
+ if (ctx.usageInputTokens === undefined && ctx.usageOutputTokens === undefined)
18
+ return undefined;
19
+ return {
20
+ inputTokens: ctx.usageInputTokens ?? 0,
21
+ outputTokens: ctx.usageOutputTokens ?? 0,
22
+ ...(ctx.usageCacheReadTokens !== undefined ? { cacheReadTokens: ctx.usageCacheReadTokens } : {}),
23
+ ...(ctx.usageCacheWriteTokens !== undefined ? { cacheWriteTokens: ctx.usageCacheWriteTokens } : {}),
24
+ ...(ctx.usageReasoningTokens !== undefined ? { reasoningTokens: ctx.usageReasoningTokens } : {}),
25
+ };
26
+ }
27
+ /**
28
+ * In-memory daily usage bucket behind the synthetic `usage/daily` event.
29
+ *
30
+ * `observe` is the single entry point: it rolls the calendar day over first
31
+ * (returning the finished day's report exactly once), then records the
32
+ * observation into the new day. Rolling over *before* recording is what keeps
33
+ * a turn ending just after midnight out of the previous day's totals.
34
+ */
35
+ export class DailyUsageAccumulator {
36
+ #day;
37
+ #bucket;
38
+ /** The day currently accumulated; `undefined` before the first observation. */
39
+ get day() {
40
+ return this.#day;
41
+ }
42
+ /**
43
+ * Roll the day over if needed, then record one observation.
44
+ *
45
+ * Returns the finished day's totals when this call crossed a day boundary
46
+ * and that day had reported usage — `undefined` on an ordinary call, on the
47
+ * first observation of a process (nothing accumulated yet), and for a day
48
+ * without usage (an empty report is noise, not a report).
49
+ */
50
+ observe(observation, now = new Date()) {
51
+ const finished = this.rollover(now);
52
+ if (observation !== undefined)
53
+ this.#record(observation);
54
+ return finished;
55
+ }
56
+ /**
57
+ * Detect a calendar-day rollover without recording anything, so the first
58
+ * event after midnight can report the day that just ended.
59
+ */
60
+ rollover(now = new Date()) {
61
+ const today = localDayKey(now);
62
+ if (this.#day === undefined) {
63
+ this.#day = today;
64
+ return undefined;
65
+ }
66
+ if (this.#day === today)
67
+ return undefined;
68
+ const bucket = this.#bucket;
69
+ const day = this.#day;
70
+ this.#day = today;
71
+ this.#bucket = undefined;
72
+ if (bucket === undefined)
73
+ return undefined;
74
+ return {
75
+ day,
76
+ totals: {
77
+ day,
78
+ inputTokens: bucket.inputTokens,
79
+ outputTokens: bucket.outputTokens,
80
+ ...(bucket.cacheReadTokens !== undefined ? { cacheReadTokens: bucket.cacheReadTokens } : {}),
81
+ ...(bucket.cacheWriteTokens !== undefined ? { cacheWriteTokens: bucket.cacheWriteTokens } : {}),
82
+ ...(bucket.reasoningTokens !== undefined ? { reasoningTokens: bucket.reasoningTokens } : {}),
83
+ turns: bucket.turns,
84
+ sessions: bucket.sessions.size,
85
+ },
86
+ };
87
+ }
88
+ /** Drop all state (plugin dispose). */
89
+ reset() {
90
+ this.#day = undefined;
91
+ this.#bucket = undefined;
92
+ }
93
+ #record(observation) {
94
+ const bucket = (this.#bucket ??= { inputTokens: 0, outputTokens: 0, turns: 0, sessions: new Set() });
95
+ bucket.inputTokens += observation.totals.inputTokens;
96
+ bucket.outputTokens += observation.totals.outputTokens;
97
+ if (observation.totals.cacheReadTokens !== undefined) {
98
+ bucket.cacheReadTokens = (bucket.cacheReadTokens ?? 0) + observation.totals.cacheReadTokens;
99
+ }
100
+ if (observation.totals.cacheWriteTokens !== undefined) {
101
+ bucket.cacheWriteTokens = (bucket.cacheWriteTokens ?? 0) + observation.totals.cacheWriteTokens;
102
+ }
103
+ if (observation.totals.reasoningTokens !== undefined) {
104
+ bucket.reasoningTokens = (bucket.reasoningTokens ?? 0) + observation.totals.reasoningTokens;
105
+ }
106
+ bucket.turns += 1;
107
+ if (observation.sessionId !== undefined)
108
+ bucket.sessions.add(observation.sessionId);
109
+ }
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hooks",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "packageManager": "pnpm@11.21.0",
5
5
  "description": "Config-driven lifecycle hooks plugin for DeepSeek Harness: declare event -> command hooks in cordis.patch.yml, no plugin code required. Includes a Hooks section in the Web GUI settings (history timeline + manual tester + notify tests + hook editor + Feishu connect).",
6
6
  "author": "PeterBon",
@@ -68,7 +68,7 @@
68
68
  "peerDependencies": {
69
69
  "@deepseek-ai/cordis": "^4.0.1",
70
70
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
71
- "@deepseek-ai/schemastery": "^3.18.1",
71
+ "@deepseek-ai/schemastery": "^3.18.2",
72
72
  "react": "^18.2.0"
73
73
  },
74
74
  "devDependencies": {
@@ -77,9 +77,9 @@
77
77
  "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
78
78
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8",
79
79
  "@deepseek-ai/dsh-session": "^0.1.0-rc.8",
80
- "@deepseek-ai/schemastery": "^3.18.1",
80
+ "@deepseek-ai/schemastery": "^3.18.2",
81
81
  "@tsdown/css": "^0.22.14",
82
- "@types/node": "^26.3.0",
82
+ "@types/node": "^26.4.1",
83
83
  "@types/react": "~18.3.1",
84
84
  "@types/react-dom": "^18.3.5",
85
85
  "react": "^18.3.1",
@@ -89,7 +89,7 @@
89
89
  "vitest": "^4.1.11"
90
90
  },
91
91
  "dependencies": {
92
- "@larksuiteoapi/node-sdk": "^1.73.0",
92
+ "@larksuiteoapi/node-sdk": "^1.73.1",
93
93
  "qrcode": "^1.5.4",
94
94
  "yaml": "^2.9.0"
95
95
  }