dsh-working-activity 0.1.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.
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Pure activity state machine for the working-activity status line. Consumes
3
+ * session events (turn/step/tool/stream) plus agent running/idle transitions
4
+ * and renders a human-readable status line at any wall-clock instant. No I/O,
5
+ * no timers, no cordis — deterministic given the event stream and a clock.
6
+ * @module @deepseek-ai/dsh-working-activity/status
7
+ */
8
+ import { actionFor, fmtDuration, isGitTool, isNight, pickPhrase, thinkingPhrase, WAITING_PHRASES, DONE_PHRASES, FAIL_PHRASES, } from './phrases.js';
9
+ /** Format one tool into its display fragment (`跑个命令 npm test`). */
10
+ function toolFragment(tool) {
11
+ return tool.detail.length === 0 ? tool.action : `${tool.action} ${tool.detail}`;
12
+ }
13
+ /** Simple non-ANSI string shortener by grapheme count. */
14
+ function shorten(value, limit) {
15
+ const graphemes = Array.from(value);
16
+ if (graphemes.length <= limit)
17
+ return value;
18
+ return `${graphemes.slice(0, Math.max(0, limit - 1)).join('')}…`;
19
+ }
20
+ /**
21
+ * Extract a displayable detail fragment from a tool call's parsed arguments.
22
+ * @param toolName - Registry tool name.
23
+ * @param args - Parsed tool arguments (lossless JSON by registry contract).
24
+ */
25
+ export function detailFor(toolName, args, limit) {
26
+ if (args === undefined)
27
+ return '';
28
+ const pickString = (...keys) => {
29
+ for (const key of keys) {
30
+ const value = args[key];
31
+ if (typeof value === 'string' && value.trim().length > 0)
32
+ return value.trim();
33
+ }
34
+ return '';
35
+ };
36
+ const normalized = toolName.toLowerCase();
37
+ if (normalized === 'mcp' || normalized.startsWith('mcp__') || normalized.includes('__')) {
38
+ const action = pickString('action', 'tool', 'server', 'connect', 'describe');
39
+ return shorten(action, limit);
40
+ }
41
+ const path = pickString('path', 'file', 'file_path', 'filepath', 'target');
42
+ if (path.length > 0)
43
+ return shorten(path, limit);
44
+ const command = pickString('command', 'cmd', 'cmdline');
45
+ if (command.length > 0)
46
+ return shorten(command, limit);
47
+ const pattern = pickString('pattern', 'query', 'search');
48
+ if (pattern.length > 0)
49
+ return shorten(pattern, limit);
50
+ const url = pickString('url');
51
+ if (url.length > 0)
52
+ return shorten(url, limit);
53
+ if (/^(?:subagent|agent|task)$/i.test(toolName)) {
54
+ const description = pickString('description');
55
+ if (description.length > 0)
56
+ return shorten(description, limit);
57
+ const prompt = pickString('prompt');
58
+ if (prompt.length > 0)
59
+ return shorten(prompt, limit);
60
+ }
61
+ const named = pickString('name', 'server', 'tool', 'id', 'goal');
62
+ if (named.length > 0)
63
+ return shorten(named, limit);
64
+ return '';
65
+ }
66
+ /**
67
+ * Track one agent's activity from its durable session events. Events from
68
+ * other sessions are ignored (the owning plugin feeds only the agent it
69
+ * displays). The tracker is deliberately single-agent: multi-session UIs
70
+ * instantiate one tracker per agent.
71
+ */
72
+ export class ActivityTracker {
73
+ config;
74
+ now;
75
+ customActions;
76
+ phase = 'idle';
77
+ phaseStartedAt = 0;
78
+ turnStartedAt = 0;
79
+ thinkingStartedAt = 0;
80
+ thinkingMs = 0;
81
+ toolMs = 0;
82
+ toolCount = 0;
83
+ activeTools = new Map();
84
+ doneQueue = [];
85
+ previousPhrase;
86
+ phraseChangedAt = 0;
87
+ waitingFirstToken = false;
88
+ /** Latest `⏵` self-narration line extracted from the stream, or null. */
89
+ narratedText = null;
90
+ /** Wall-clock time of the most recent stream delta (narration freshness). */
91
+ lastChunkAt = 0;
92
+ /** Rolling stream buffer (reasoning + text deltas) for `⏵` extraction. */
93
+ recentStream = '';
94
+ /** Total tokens reported across the turn's assistant messages. */
95
+ turnTokens = 0;
96
+ /** Completion prefix drawn ONCE at turn end so the done line stays stable. */
97
+ donePrefix = '搞定 ✓';
98
+ /**
99
+ * @param config - Behavioral knobs.
100
+ * @param now - Wall-clock supplier (injectable for tests).
101
+ * @param customActions - Exact-name custom action pools for {@link actionFor}.
102
+ */
103
+ constructor(config, now = Date.now, customActions) {
104
+ this.config = config;
105
+ this.now = now;
106
+ this.customActions = customActions;
107
+ }
108
+ /** Agent transitioned to running/idle. */
109
+ onAgentStatus(status) {
110
+ if (status === 'idle') {
111
+ // The turn end already moved us to the done phase; idle only clears the
112
+ // lingering done card after its display window.
113
+ if (this.phase !== 'done')
114
+ this.phase = 'idle';
115
+ return;
116
+ }
117
+ if (this.phase === 'idle') {
118
+ this.phase = 'waiting';
119
+ this.phaseStartedAt = this.now();
120
+ this.waitingFirstToken = true;
121
+ }
122
+ }
123
+ /** Consume one durable session event (turn/step/tool/stream). */
124
+ onSessionEvent(event) {
125
+ switch (event.type) {
126
+ case 'turn/start': {
127
+ const at = event.time;
128
+ this.turnStartedAt = at;
129
+ this.thinkingStartedAt = at;
130
+ this.thinkingMs = 0;
131
+ this.toolMs = 0;
132
+ this.toolCount = 0;
133
+ this.turnTokens = 0;
134
+ this.activeTools.clear();
135
+ this.doneQueue = [];
136
+ this.waitingFirstToken = true;
137
+ this.narratedText = null;
138
+ this.lastChunkAt = 0;
139
+ this.recentStream = '';
140
+ this.setPhase('waiting', at);
141
+ return;
142
+ }
143
+ case 'step/start':
144
+ if (this.phase === 'waiting' && !this.waitingFirstToken) {
145
+ // A new step without streamed output yet — stay waiting.
146
+ }
147
+ return;
148
+ case 'assistant/chunk': {
149
+ const chunk = event.data.chunk;
150
+ this.lastChunkAt = event.time;
151
+ if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
152
+ if (this.waitingFirstToken) {
153
+ this.waitingFirstToken = false;
154
+ this.setPhase('thinking', event.time);
155
+ this.thinkingStartedAt = event.time;
156
+ }
157
+ this.recentStream = (this.recentStream + chunk.text).slice(-STREAM_BUFFER_CHARS);
158
+ const narration = extractNarration(this.recentStream);
159
+ if (narration !== null)
160
+ this.narratedText = narration;
161
+ }
162
+ return;
163
+ }
164
+ case 'assistant/message': {
165
+ const usage = event.data.usage;
166
+ if (usage !== undefined) {
167
+ this.turnTokens += usage.inputTokens + usage.outputTokens
168
+ + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
169
+ }
170
+ return;
171
+ }
172
+ case 'tool/call': {
173
+ const at = event.time;
174
+ if (this.phase === 'thinking' || this.phase === 'waiting') {
175
+ this.thinkingMs += at - this.thinkingStartedAt;
176
+ }
177
+ const parsed = parseArguments(event.data.arguments);
178
+ const action = this.config.phrases ? actionFor(event.data.name, this.customActions) : event.data.name;
179
+ const detail = detailFor(event.data.name, parsed, this.config.detailLimit);
180
+ const active = {
181
+ callId: event.data.callId,
182
+ name: event.data.name,
183
+ action,
184
+ detail,
185
+ isGit: isGitTool(event.data.name, parsed),
186
+ startedAt: at,
187
+ failed: false,
188
+ };
189
+ this.activeTools.set(event.data.callId, active);
190
+ this.setPhase('tool', at);
191
+ return;
192
+ }
193
+ case 'tool/result': {
194
+ const at = event.time;
195
+ // `ToolResultMessage.content` is the single-block `[ToolResultBlock]`
196
+ // tuple, so `block` is never absent and always a tool-result block.
197
+ const block = event.data.message.content[0];
198
+ const active = this.activeTools.get(block.toolCallId);
199
+ if (active === undefined)
200
+ return;
201
+ active.failed = event.data.error !== undefined || block.isError === true;
202
+ active.endedAt = at;
203
+ this.toolMs += at - active.startedAt;
204
+ this.toolCount += 1;
205
+ this.doneQueue.push({
206
+ action: active.action,
207
+ detail: active.detail,
208
+ failed: active.failed,
209
+ endedAt: at,
210
+ });
211
+ if (this.doneQueue.length > DONE_QUEUE_MAX)
212
+ this.doneQueue.shift();
213
+ this.activeTools.delete(block.toolCallId);
214
+ if (this.activeTools.size === 0) {
215
+ // Back to thinking (or a trailing done card if the turn just closed).
216
+ this.setPhase('thinking', at);
217
+ this.thinkingStartedAt = at;
218
+ }
219
+ return;
220
+ }
221
+ case 'turn/end': {
222
+ const at = event.time;
223
+ if (this.activeTools.size > 0) {
224
+ // Tools still running at turn end: count their elapsed time as tool time.
225
+ for (const tool of this.activeTools.values()) {
226
+ this.toolMs += Math.max(0, at - tool.startedAt);
227
+ }
228
+ this.activeTools.clear();
229
+ }
230
+ else if (this.phase === 'thinking' || this.phase === 'waiting') {
231
+ this.thinkingMs += Math.max(0, at - this.thinkingStartedAt);
232
+ }
233
+ // Draw the completion prefix ONCE so repeated renders of the done line
234
+ // stay stable (a fresh random per render would make it flicker).
235
+ const lastTool = this.doneQueue.at(-1);
236
+ if (this.config.phrases) {
237
+ this.donePrefix = lastTool?.failed ? pickPhrase(FAIL_PHRASES) : pickPhrase(DONE_PHRASES);
238
+ }
239
+ else {
240
+ this.donePrefix = '搞定 ✓';
241
+ }
242
+ this.setPhase('done', at);
243
+ return;
244
+ }
245
+ default:
246
+ return;
247
+ }
248
+ }
249
+ /** Render the current status snapshot at a wall-clock instant. */
250
+ render(nowMs = this.now()) {
251
+ switch (this.phase) {
252
+ case 'idle':
253
+ return {
254
+ phase: 'idle',
255
+ line: '',
256
+ toolCount: 0,
257
+ turnElapsedMs: 0,
258
+ phaseStartedAt: this.phaseStartedAt,
259
+ };
260
+ case 'done': {
261
+ const summary = this.doneSummary(nowMs);
262
+ return {
263
+ phase: 'done',
264
+ line: summary.line,
265
+ toolCount: this.toolCount,
266
+ turnElapsedMs: this.turnElapsedMs(nowMs),
267
+ phaseStartedAt: this.phaseStartedAt,
268
+ ...(summary.phrase === undefined ? {} : { phrase: summary.phrase }),
269
+ };
270
+ }
271
+ case 'tool': {
272
+ const tool = this.primaryTool();
273
+ if (tool === undefined) {
274
+ return this.renderThinking(nowMs);
275
+ }
276
+ const fragment = toolFragment(tool);
277
+ const elapsed = fmtDuration(Math.max(0, nowMs - tool.startedAt));
278
+ const git = tool.isGit ? ' · git' : '';
279
+ const narration = this.freshNarration(nowMs);
280
+ const line = narration === null
281
+ ? `${fragment} · ${elapsed}${git}`
282
+ : `⏵ ${narration} · ${fragment} · ${elapsed}${git}`;
283
+ return {
284
+ phase: 'tool',
285
+ line,
286
+ label: tool.action,
287
+ detail: tool.detail,
288
+ ...(narration === null ? {} : { phrase: narration }),
289
+ toolCount: this.toolCount,
290
+ turnElapsedMs: this.turnElapsedMs(nowMs),
291
+ phaseStartedAt: this.phaseStartedAt,
292
+ };
293
+ }
294
+ case 'waiting':
295
+ case 'thinking': {
296
+ const rendered = this.renderThinking(nowMs);
297
+ if (this.phase === 'waiting') {
298
+ return { ...rendered, phase: 'waiting' };
299
+ }
300
+ return rendered;
301
+ }
302
+ }
303
+ }
304
+ /** Per-turn thinking/tooling split for stats consumers. */
305
+ stats() {
306
+ return {
307
+ thinkingMs: this.thinkingMs,
308
+ toolMs: this.toolMs,
309
+ toolCount: this.toolCount,
310
+ };
311
+ }
312
+ renderThinking(nowMs) {
313
+ const thinkingMs = this.phase === 'waiting'
314
+ ? 0
315
+ : this.thinkingMs + Math.max(0, nowMs - this.thinkingStartedAt);
316
+ const elapsed = fmtDuration(this.turnElapsedMs(nowMs));
317
+ const narration = this.freshNarration(nowMs);
318
+ if (narration !== null) {
319
+ return {
320
+ phase: this.phase,
321
+ line: `⏵ ${narration} · 总${elapsed}`,
322
+ phrase: narration,
323
+ toolCount: this.toolCount,
324
+ turnElapsedMs: this.turnElapsedMs(nowMs),
325
+ phaseStartedAt: this.phaseStartedAt,
326
+ };
327
+ }
328
+ if (this.config.phrases) {
329
+ if (nowMs - this.phraseChangedAt >= PHRASE_ROTATE_MS) {
330
+ // Waiting (pre-first-token) draws from the waiting pool; thinking
331
+ // rotates the playful copy pool with night mixing.
332
+ this.previousPhrase = this.phase === 'waiting'
333
+ ? pickPhrase(WAITING_PHRASES, this.previousPhrase)
334
+ : thinkingPhrase(thinkingMs, this.previousPhrase, isNight(new Date(nowMs).getHours()));
335
+ this.phraseChangedAt = nowMs;
336
+ }
337
+ const phrase = this.previousPhrase ?? (this.phase === 'waiting'
338
+ ? pickPhrase(WAITING_PHRASES)
339
+ : thinkingPhrase(thinkingMs, undefined, isNight(new Date(nowMs).getHours())));
340
+ return {
341
+ phase: this.phase,
342
+ line: `${phrase} · 总${elapsed}`,
343
+ phrase,
344
+ toolCount: this.toolCount,
345
+ turnElapsedMs: this.turnElapsedMs(nowMs),
346
+ phaseStartedAt: this.phaseStartedAt,
347
+ };
348
+ }
349
+ const label = this.phase === 'waiting' ? '等待模型响应' : '思考中';
350
+ return {
351
+ phase: this.phase,
352
+ line: `${label} · 总${elapsed}`,
353
+ label,
354
+ toolCount: this.toolCount,
355
+ turnElapsedMs: this.turnElapsedMs(nowMs),
356
+ phaseStartedAt: this.phaseStartedAt,
357
+ };
358
+ }
359
+ doneSummary(nowMs) {
360
+ const { thinkingMs, toolMs, toolCount } = this.stats();
361
+ const tokens = this.turnTokens > 0 ? ` · 🔥 ${fmtTokens(this.turnTokens)}` : '';
362
+ const base = `${this.donePrefix} · ${toolCount} 工具 · 想${fmtDuration(thinkingMs)} 干${fmtDuration(toolMs)}${tokens}`;
363
+ if (!this.config.phrases) {
364
+ return { line: `搞定 ✓ · ${toolCount} 工具 · 想${fmtDuration(thinkingMs)} 干${fmtDuration(toolMs)}${tokens}` };
365
+ }
366
+ const last = this.doneQueue.at(-1);
367
+ if (last !== undefined && nowMs - last.endedAt < DONE_FRAGMENT_MS) {
368
+ const fragment = toolFragment(last);
369
+ return { line: `${this.donePrefix} · ${fragment} · ${toolCount} 工具${tokens}`, phrase: this.donePrefix };
370
+ }
371
+ return { line: base, ...(this.donePrefix === '搞定 ✓' ? {} : { phrase: this.donePrefix }) };
372
+ }
373
+ /** The fresh self-narration line, or null once the stream has been quiet. */
374
+ freshNarration(nowMs) {
375
+ if (this.narratedText === null)
376
+ return null;
377
+ if (nowMs - this.lastChunkAt > NARRATE_GRACE_MS)
378
+ return null;
379
+ return this.narratedText;
380
+ }
381
+ primaryTool() {
382
+ let primary;
383
+ for (const tool of this.activeTools.values()) {
384
+ if (primary === undefined || tool.startedAt < primary.startedAt)
385
+ primary = tool;
386
+ }
387
+ return primary;
388
+ }
389
+ turnElapsedMs(nowMs) {
390
+ return this.turnStartedAt === 0 ? 0 : Math.max(0, nowMs - this.turnStartedAt);
391
+ }
392
+ setPhase(phase, atMs) {
393
+ this.phase = phase;
394
+ this.phaseStartedAt = atMs;
395
+ }
396
+ }
397
+ /** Rotate the thinking phrase every N render ticks (render cadence ≈ 500ms → ~4s). */
398
+ const PHRASE_ROTATE_MS = 4000;
399
+ /** Cap on replayed done cards; older entries drop. */
400
+ const DONE_QUEUE_MAX = 6;
401
+ /** Show the last tool's fragment in the done line for this long after it ends. */
402
+ const DONE_FRAGMENT_MS = 3000;
403
+ /** Rolling stream buffer size for `⏵` narration extraction. */
404
+ const STREAM_BUFFER_CHARS = 300;
405
+ /** A narration stays visible this long after the stream went quiet. */
406
+ const NARRATE_GRACE_MS = 5000;
407
+ /** Extract the latest `⏵` self-narration line from a stream buffer. */
408
+ export function extractNarration(buffer) {
409
+ const matches = [...buffer.matchAll(/⏵\s*([^\n⏵]{1,40})/g)];
410
+ if (matches.length === 0)
411
+ return null;
412
+ const latest = matches[matches.length - 1]?.[1];
413
+ if (latest === undefined)
414
+ return null;
415
+ const text = latest.replace(/[。..!!,,、;;]+$/, '').trim();
416
+ return text.length === 0 ? null : text;
417
+ }
418
+ /** Format a token count compactly (`12.3k`, `1.2M`). */
419
+ function fmtTokens(tokens) {
420
+ if (tokens >= 1_000_000)
421
+ return `${(tokens / 1_000_000).toFixed(1)}M`;
422
+ if (tokens >= 1000)
423
+ return `${(tokens / 1000).toFixed(1)}k`;
424
+ return String(tokens);
425
+ }
426
+ /** Parse a tool call's raw arguments JSON defensively. */
427
+ function parseArguments(raw) {
428
+ if (raw.trim().length === 0)
429
+ return undefined;
430
+ try {
431
+ const parsed = JSON.parse(raw);
432
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
433
+ return parsed;
434
+ }
435
+ return undefined;
436
+ }
437
+ catch {
438
+ return undefined;
439
+ }
440
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "dsh-working-activity",
3
+ "description": "Live model working-status line: playful copy, running tool, turn elapsed — for TUI prompt and Web UI",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/types/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/index.d.ts",
11
+ "default": "./lib/types/index.js"
12
+ },
13
+ "./events": {
14
+ "types": "./lib/types/events.d.ts",
15
+ "default": "./lib/types/events.js"
16
+ },
17
+ "./invariant": {
18
+ "types": "./lib/types/invariant.d.ts",
19
+ "default": "./lib/types/invariant.js"
20
+ },
21
+ "./status": {
22
+ "types": "./lib/types/status.d.ts",
23
+ "default": "./lib/types/status.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib",
30
+ "src"
31
+ ],
32
+ "engines": {
33
+ "node": "^22.19 || >=24"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.json"
37
+ },
38
+ "license": "BSD-3-Clause",
39
+ "dependencies": {
40
+ "@deepseek-ai/schemastery": "^3.18.1"
41
+ },
42
+ "peerDependencies": {
43
+ "@deepseek-ai/cordis": "^4.0.1",
44
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
45
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
46
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
47
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6"
48
+ },
49
+ "devDependencies": {
50
+ "@deepseek-ai/cordis": "^4.0.1",
51
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
52
+ "@deepseek-ai/dsh-agent-loop": "^0.1.0-rc.6",
53
+ "@deepseek-ai/dsh-agent-loop-testkit": "^0.1.0-rc.6",
54
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
55
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
56
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
57
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
58
+ "@types/node": "^22.0.0",
59
+ "typescript": "^6.0.3"
60
+ }
61
+ }
package/src/events.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `activity/status` session event — a log-only, non-surface snapshot of the
3
+ * model's current working activity, published by this plugin for any UI
4
+ * consumer (Web client, telemetry, …). It never enters derived model history
5
+ * (no `surfaceOp`), so it cannot leak into prompts; UIs render it like
6
+ * `todo/write` or `plan/mode`.
7
+ * @module @deepseek-ai/dsh-working-activity/events
8
+ */
9
+
10
+ import type { ActivityPhase } from './status.js'
11
+
12
+ /** Durable payload of one `activity/status` snapshot. */
13
+ export interface ActivityStatusEvent {
14
+ /** Which activity phase the model is in. */
15
+ readonly phase: ActivityPhase
16
+ /** Human-readable status line (plain text, no ANSI). */
17
+ readonly line: string
18
+ /** Short label of the current work, when any. */
19
+ readonly label?: string
20
+ /** Detail fragment (path / command / pattern), when any. */
21
+ readonly detail?: string
22
+ /** The playful phrase currently shown, when the copy pool is on. */
23
+ readonly phrase?: string
24
+ /** Tools completed in the current turn. */
25
+ readonly toolCount: number
26
+ /** Milliseconds since the current turn started (0 when idle). */
27
+ readonly turnElapsedMs: number
28
+ /** Wall-clock time (epoch ms) the current phase started, for animations. */
29
+ readonly phaseStartedAt: number
30
+ }
31
+
32
+ /** The `activity/status` phase vocabulary, exported for wire consumers. */
33
+ export type { ActivityPhase }
34
+
35
+ declare module '@deepseek-ai/dsh-session/types' {
36
+ interface SessionEventMap {
37
+ /**
38
+ * Log-only UI snapshot of the model's working activity (thinking copy,
39
+ * running tool, turn elapsed). Never a surface event: UIs render it, the
40
+ * model never sees it.
41
+ * @param data - The rendered status snapshot.
42
+ */
43
+ 'activity/status': ActivityStatusEvent
44
+ }
45
+ }