dsh-hooks 0.11.0 → 0.13.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/lib/tail.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Incremental reader for the hook history JSONL log, behind
3
+ * `dsh-hooks tail`. It follows the same rules as the history sink — complete
4
+ * lines only, a shrinking file means rotation/truncation and resets the
5
+ * offset, a broken line never throws — but it is strictly read-only and keeps
6
+ * no ring buffer: it hands each new batch to the caller.
7
+ *
8
+ * The reader tracks a byte offset plus the trailing fragment of the last read
9
+ * (a line can be observed mid-write). Decoding happens per chunk, exactly like
10
+ * the sink does, so a multibyte character split across two reads can cost one
11
+ * malformed line at worst — never a crash.
12
+ */
13
+ import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
14
+ /** Bytes of the file tail read for the initial backfill. */
15
+ export const TAIL_BACKFILL_BYTES = 64 * 1024;
16
+ /** Does a record pass every configured filter? */
17
+ export function matchesTailFilter(record, filter) {
18
+ if (filter.event !== undefined && record.event !== filter.event)
19
+ return false;
20
+ if (filter.outcome !== undefined && record.outcome !== filter.outcome)
21
+ return false;
22
+ if (filter.hook !== undefined && !record.command.includes(filter.hook))
23
+ return false;
24
+ return true;
25
+ }
26
+ /** Local `HH:MM:SS` stamp for a record's epoch-ms timestamp. */
27
+ function clockTime(ts) {
28
+ const date = new Date(ts);
29
+ const pad = (n) => String(n).padStart(2, '0');
30
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
31
+ }
32
+ /** One human-readable line, aligned with the Web GUI's history timeline. */
33
+ export function formatTailRecord(record) {
34
+ const parts = [clockTime(record.ts), record.event, record.command, record.outcome];
35
+ if (record.durationMs !== undefined)
36
+ parts.push(`${record.durationMs}ms`);
37
+ if (record.exitCode !== undefined && record.exitCode !== 0)
38
+ parts.push(`exit=${record.exitCode}`);
39
+ if (record.sessionName || record.sessionId)
40
+ parts.push(record.sessionName ?? String(record.sessionId));
41
+ let line = parts.join(' ');
42
+ if (record.error)
43
+ line += `\n ${record.error.replace(/\s+/g, ' ').slice(0, 300)}`;
44
+ return line;
45
+ }
46
+ /** Parse one JSONL line into a record, or undefined when it is unreadable. */
47
+ function parseRecord(line) {
48
+ try {
49
+ const entry = JSON.parse(line);
50
+ if (typeof entry !== 'object' || entry === null || typeof entry.ts !== 'number')
51
+ return undefined;
52
+ return entry;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ export class HistoryTailer {
59
+ file;
60
+ #offset = 0;
61
+ #pending = '';
62
+ constructor(file) {
63
+ this.file = file;
64
+ }
65
+ /** Bytes already consumed (the next read starts here). */
66
+ get offset() {
67
+ return this.#offset;
68
+ }
69
+ /** Read `[start, end)` as text (best-effort: returns '' when unreadable). */
70
+ #read(start, end) {
71
+ const length = end - start;
72
+ if (length <= 0)
73
+ return '';
74
+ const fd = openSync(this.file, 'r');
75
+ try {
76
+ const chunk = Buffer.allocUnsafe(length);
77
+ let total = 0;
78
+ while (total < length) {
79
+ const read = readSync(fd, chunk, total, length - total, start + total);
80
+ if (read <= 0)
81
+ break;
82
+ total += read;
83
+ }
84
+ return chunk.subarray(0, total).toString('utf8');
85
+ }
86
+ finally {
87
+ closeSync(fd);
88
+ }
89
+ }
90
+ /**
91
+ * The last `limit` records (`limit <= 0` = all of them), read from at most
92
+ * `maxBytes` of the file tail so a large log is never slurped just to print
93
+ * a few lines. Leaves the reader positioned at EOF, so the following
94
+ * {@link readNew} only reports what is appended afterwards.
95
+ */
96
+ backfill(limit = 10, maxBytes = TAIL_BACKFILL_BYTES) {
97
+ if (!existsSync(this.file))
98
+ return [];
99
+ const size = statSync(this.file).size;
100
+ if (size === 0) {
101
+ this.#offset = 0;
102
+ this.#pending = '';
103
+ return [];
104
+ }
105
+ const start = Math.max(0, size - Math.max(0, maxBytes));
106
+ const text = this.#read(start, size);
107
+ this.#offset = size;
108
+ this.#pending = '';
109
+ let lines = text.split('\n');
110
+ // Reading from the middle of the file starts mid-line: drop that fragment.
111
+ if (start > 0)
112
+ lines = lines.slice(1);
113
+ const records = [];
114
+ for (const line of lines) {
115
+ if (line === '')
116
+ continue;
117
+ const record = parseRecord(line);
118
+ if (record !== undefined)
119
+ records.push(record);
120
+ }
121
+ return limit > 0 ? records.slice(-limit) : records;
122
+ }
123
+ /**
124
+ * Complete lines appended since the previous call. A file that shrank since
125
+ * then was rotated/truncated: reading restarts from 0 and `reset` is set so
126
+ * the caller can say so out loud.
127
+ */
128
+ readNew() {
129
+ const batch = { records: [], lines: [], reset: false };
130
+ if (!existsSync(this.file))
131
+ return batch;
132
+ const size = statSync(this.file).size;
133
+ if (size < this.#offset) {
134
+ this.#offset = 0;
135
+ this.#pending = '';
136
+ batch.reset = true;
137
+ }
138
+ if (size === this.#offset)
139
+ return batch;
140
+ const text = this.#pending + this.#read(this.#offset, size);
141
+ this.#offset = size;
142
+ const parts = text.split('\n');
143
+ this.#pending = parts.pop() ?? '';
144
+ for (const line of parts) {
145
+ if (line === '')
146
+ continue;
147
+ batch.lines.push(line);
148
+ const record = parseRecord(line);
149
+ if (record !== undefined)
150
+ batch.records.push(record);
151
+ }
152
+ return batch;
153
+ }
154
+ }
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.13.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
  }