dsh-working-activity 0.2.0 → 0.2.2

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/src/events.ts CHANGED
@@ -1,45 +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
- }
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
+ }
package/src/index.ts CHANGED
@@ -1,211 +1,217 @@
1
- /**
2
- * working-activity — a live "working line" for DeepSeek Harness agents.
3
- *
4
- * Folds the durable session stream (turn/step/tool/stream events) plus
5
- * `agent/status` into a playful real-time status line, then publishes it two
6
- * ways, both optional:
7
- *
8
- * - TUI: registers the `${activity}` prompt slot on `ctx.tuiPrompt` when the
9
- * TUI is composed; add `${activity}` to `theme.leftPrompt` to see it.
10
- * - Session log: appends log-only `activity/status` events (never surface
11
- * events) for Web and other UI consumers; replay ignores them.
12
- *
13
- * The state machine itself lives in `./status.ts` (pure, clock-injected); this
14
- * module only wires events, the render tick, and the two sinks.
15
- * @module @deepseek-ai/dsh-working-activity
16
- */
17
-
18
- import type { Context } from '@deepseek-ai/cordis'
19
- import z from '@deepseek-ai/schemastery'
20
- import type { Session } from '@deepseek-ai/dsh-session'
21
- // Type-only: resolves the agent/status cordis event declaration.
22
- import type {} from '@deepseek-ai/dsh-agent'
23
- // Type-only: resolves ctx.systemPrompt for the narration section injection.
24
- import type {} from '@deepseek-ai/dsh-system-prompt'
25
- import { ActivityTracker } from './status.js'
26
- import type { ActivityState } from './status.js'
27
- import type { ActivityStatusEvent } from './events.js'
28
- // Re-export the event type + SessionEventMap merge: the package root must carry
29
- // the declare-module side effect for consumers resolving the built d.ts.
30
- export type * from './events.js'
31
-
32
- export const name = 'working-activity'
33
-
34
- /** Configurable knobs; every key has a sane default. */
35
- export type Config = {
36
- /** Playful copy pool; false renders plain functional labels. */
37
- phrases?: boolean
38
- /** Append `activity/status` session events for UI consumers. */
39
- publish?: boolean
40
- /** Status render tick interval in ms. */
41
- tickMs?: number
42
- /** Minimum interval in ms between published events while the line is stable. */
43
- publishIntervalMs?: number
44
- /** Maximum displayed detail length (paths/commands/patterns). */
45
- detailLimit?: number
46
- /** Exact tool-name action-copy pools (case-insensitive match). */
47
- customActions?: Record<string, string[]>
48
- /** Inject the `⏵` self-narration contract into the system prompt and surface it. */
49
- narrate?: boolean
50
- }
51
-
52
- export const Config = z.object({
53
- phrases: z.boolean().default(true),
54
- publish: z.boolean().default(true),
55
- tickMs: z.number().step(50).min(100).max(5000).default(500),
56
- publishIntervalMs: z.number().step(500).min(500).max(30_000).default(2000),
57
- detailLimit: z.number().step(1).min(8).max(120).default(40),
58
- customActions: z.dict(z.array(z.string())).default({}),
59
- narrate: z.boolean().default(true),
60
- })
61
-
62
- /** Structural view of the TUI prompt service; the real type lives in dsh-tui. */
63
- interface TuiPromptLike {
64
- register(name: string, initialValue?: string): {
65
- set(value: string | undefined): void
66
- dispose(): void
67
- }
68
- }
69
-
70
- /** Resolved plugin configuration after schema defaults. */
71
- interface ResolvedConfig {
72
- phrases: boolean
73
- publish: boolean
74
- tickMs: number
75
- publishIntervalMs: number
76
- detailLimit: number
77
- customActions: Record<string, string[]>
78
- narrate: boolean
79
- }
80
-
81
- /** The self-narration contract injected into the system prompt (narrate on). */
82
- const NARRATE_INSTRUCTION =
83
- '[状态栏] 你有一个状态栏展示给用户。【必须】在每个步骤/子任务开始时(不只是调用工具前),在回复正文的最前面单独写一行:⏵ 你在做的具体事情(不超过20字),然后换行继续正常回复。整轮回复只写一行 ⏵,不要重复。信息为主——让人一眼知道你在干什么,风格自然、可以带点俏皮。例:⏵ 修复登录页样式、⏵ 查一下报错原因、⏵ 给补丁跑个验证。切换任务时必须更新。'
84
-
85
- /**
86
- * Wire the working-activity plugin.
87
- * @param ctx - Cordis context (agent loop + session services composed).
88
- * @param config - Validated plugin config (schema defaults applied).
89
- */
90
- export function apply(ctx: Context, config: Config = {}): void {
91
- const resolved: ResolvedConfig = {
92
- phrases: config.phrases ?? true,
93
- publish: config.publish ?? true,
94
- tickMs: config.tickMs ?? 500,
95
- publishIntervalMs: config.publishIntervalMs ?? 2000,
96
- detailLimit: config.detailLimit ?? 40,
97
- narrate: config.narrate ?? true,
98
- customActions: config.customActions ?? {},
99
- }
100
- const trackers = new Map<Session, ActivityTracker>()
101
- let activeSession: Session | undefined
102
- let lastPublishedLine: string | undefined
103
- let lastPublishedPhase: string | undefined
104
- let lastPublishAt = 0
105
-
106
- // Optional TUI seam: no TUI composed -> no slot, no error. The register()
107
- // call is itself effect-owned, so fiber disposal unregisters the slot.
108
- const prompt = ctx.get('tuiPrompt', false) as TuiPromptLike | undefined
109
- const promptHandle = prompt?.register('activity', undefined)
110
-
111
- // The `⏵` self-narration contract rides the stable system-prompt sections:
112
- // injected when the systemPrompt service is composed (agent assemblies
113
- // always mount it), removed with this fiber.
114
- if (resolved.narrate) {
115
- ctx.inject(['systemPrompt'], (promptCtx) => {
116
- promptCtx.systemPrompt.section({
117
- name: 'working-activity:narrate',
118
- order: 60,
119
- text: NARRATE_INSTRUCTION,
120
- })
121
- })
122
- }
123
-
124
- const trackerFor = (session: Session): ActivityTracker => {
125
- let tracker = trackers.get(session)
126
- if (tracker === undefined) {
127
- tracker = new ActivityTracker(
128
- { phrases: resolved.phrases, detailLimit: resolved.detailLimit, showIdle: false },
129
- Date.now,
130
- resolved.customActions,
131
- )
132
- trackers.set(session, tracker)
133
- }
134
- return tracker
135
- }
136
-
137
- /**
138
- * Publish one rendered snapshot: TUI slot update + throttled session event.
139
- * Callers snapshot the tracker state at event time and hand it here, so a
140
- * burst of fast events (e.g. a synchronous tool call+result) cannot lose an
141
- * intermediate phase; the append itself runs inside a microtask because the
142
- * session's appending guard is still set while session/event callbacks run.
143
- */
144
- const publish = (session: Session, state: ActivityState): void => {
145
- queueMicrotask(() => {
146
- const line = state.phase === 'idle' ? undefined : state.line
147
- promptHandle?.set(line)
148
- if (!resolved.publish) return
149
- const nowMs = Date.now()
150
- const lineChanged = state.line !== lastPublishedLine
151
- const phaseChanged = state.phase !== lastPublishedPhase
152
- // Live phases republish on a throttle so elapsed times stay current;
153
- // settled phases (idle/done) publish only when the line itself changes.
154
- const liveThrottle = state.phase !== 'idle' && state.phase !== 'done'
155
- && nowMs - lastPublishAt >= resolved.publishIntervalMs
156
- if (!lineChanged && !phaseChanged && !liveThrottle) return
157
- // Optional fields must be omitted (not undefined): session append rejects
158
- // data JSON would discard, and `activity/status` is a lossless-JSON event.
159
- const payload: ActivityStatusEvent = {
160
- phase: state.phase,
161
- line: state.line,
162
- toolCount: state.toolCount,
163
- turnElapsedMs: state.turnElapsedMs,
164
- phaseStartedAt: state.phaseStartedAt,
165
- ...(state.label === undefined ? {} : { label: state.label }),
166
- ...(state.detail === undefined ? {} : { detail: state.detail }),
167
- ...(state.phrase === undefined ? {} : { phrase: state.phrase }),
168
- }
169
- try {
170
- session.append('activity/status', payload)
171
- lastPublishedLine = state.line
172
- lastPublishedPhase = state.phase
173
- lastPublishAt = nowMs
174
- } catch {
175
- // Session closed or the append guard still held: drop this snapshot;
176
- // the next tick retries the same line.
177
- }
178
- })
179
- }
180
-
181
- ctx.on('session/event', (session, event) => {
182
- const tracker = trackerFor(session)
183
- tracker.onSessionEvent(event)
184
- activeSession = session
185
- publish(session, tracker.render())
186
- })
187
-
188
- ctx.on('session/disposed', (session) => {
189
- trackers.delete(session)
190
- if (activeSession === session) activeSession = undefined
191
- })
192
-
193
- ctx.on('agent/status', ({ agent, status }) => {
194
- const session = agent.session
195
- const tracker = trackerFor(session)
196
- tracker.onAgentStatus(status)
197
- activeSession = session
198
- publish(session, tracker.render())
199
- })
200
-
201
- // Continuous tick: elapsed times and the phrase rotation move on their own.
202
- // A manual timer keeps this plugin free of the @cordisjs/plugin-timer mixin;
203
- // the effect disposer clears it when this fiber unloads.
204
- const tickTimer = setInterval(() => {
205
- if (activeSession === undefined) return
206
- const tracker = trackers.get(activeSession)
207
- if (tracker === undefined) return
208
- publish(activeSession, tracker.render())
209
- }, resolved.tickMs)
210
- ctx.effect(() => () => { clearInterval(tickTimer) }, 'working-activity tick timer')
211
- }
1
+ /**
2
+ * working-activity — a live "working line" for DeepSeek Harness agents.
3
+ *
4
+ * Folds the durable session stream (turn/step/tool/stream events) plus
5
+ * `agent/status` into a playful real-time status line, then publishes it two
6
+ * ways, both optional:
7
+ *
8
+ * - TUI: registers the `${activity}` prompt slot on `ctx.tuiPrompt` when the
9
+ * TUI is composed; add `${activity}` to `theme.leftPrompt` to see it.
10
+ * - Session log: appends log-only `activity/status` events (never surface
11
+ * events) for Web and other UI consumers; replay ignores them.
12
+ *
13
+ * The state machine itself lives in `./status.ts` (pure, clock-injected); this
14
+ * module only wires events, the render tick, and the two sinks.
15
+ * @module @deepseek-ai/dsh-working-activity
16
+ */
17
+
18
+ import type { Context } from '@deepseek-ai/cordis'
19
+ import z from '@deepseek-ai/schemastery'
20
+ import type { Session } from '@deepseek-ai/dsh-session'
21
+ // Type-only: resolves the agent/status cordis event declaration.
22
+ import type {} from '@deepseek-ai/dsh-agent'
23
+ // Type-only: resolves ctx.systemPrompt for the narration section injection.
24
+ import type {} from '@deepseek-ai/dsh-system-prompt'
25
+ import { ActivityTracker } from './status.js'
26
+ import type { ActivityState } from './status.js'
27
+ import type { ActivityStatusEvent } from './events.js'
28
+ // Re-export the event type + SessionEventMap merge: the package root must carry
29
+ // the declare-module side effect for consumers resolving the built d.ts.
30
+ export type * from './events.js'
31
+
32
+ export const name = 'working-activity'
33
+
34
+ /** Configurable knobs; every key has a sane default. */
35
+ export type Config = {
36
+ /** Playful copy pool; false renders plain functional labels. */
37
+ phrases?: boolean
38
+ /** Append `activity/status` session events for UI consumers. Default OFF:
39
+ * dsh-session's append() cannot mark events ignorable, and the resume
40
+ * read path refuses logs containing unknown non-ignorable types — every
41
+ * appended snapshot makes the whole session unresumable. Re-enable only
42
+ * for a log-replaying consumer on a harness that supports ignorable
43
+ * appends. The live status line (prompt slot / session events) is
44
+ * unaffected by this flag. */
45
+ publish?: boolean
46
+ /** Status render tick interval in ms. */
47
+ tickMs?: number
48
+ /** Minimum interval in ms between published events while the line is stable. */
49
+ publishIntervalMs?: number
50
+ /** Maximum displayed detail length (paths/commands/patterns). */
51
+ detailLimit?: number
52
+ /** Exact tool-name action-copy pools (case-insensitive match). */
53
+ customActions?: Record<string, string[]>
54
+ /** Inject the `⏵` self-narration contract into the system prompt and surface it. */
55
+ narrate?: boolean
56
+ }
57
+
58
+ export const Config = z.object({
59
+ phrases: z.boolean().default(true),
60
+ publish: z.boolean().default(false),
61
+ tickMs: z.number().step(50).min(100).max(5000).default(500),
62
+ publishIntervalMs: z.number().step(500).min(500).max(30_000).default(2000),
63
+ detailLimit: z.number().step(1).min(8).max(120).default(40),
64
+ customActions: z.dict(z.array(z.string())).default({}),
65
+ narrate: z.boolean().default(true),
66
+ })
67
+
68
+ /** Structural view of the TUI prompt service; the real type lives in dsh-tui. */
69
+ interface TuiPromptLike {
70
+ register(name: string, initialValue?: string): {
71
+ set(value: string | undefined): void
72
+ dispose(): void
73
+ }
74
+ }
75
+
76
+ /** Resolved plugin configuration after schema defaults. */
77
+ interface ResolvedConfig {
78
+ phrases: boolean
79
+ publish: boolean
80
+ tickMs: number
81
+ publishIntervalMs: number
82
+ detailLimit: number
83
+ customActions: Record<string, string[]>
84
+ narrate: boolean
85
+ }
86
+
87
+ /** The self-narration contract injected into the system prompt (narrate on). */
88
+ const NARRATE_INSTRUCTION =
89
+ '[状态栏] 你有一个状态栏展示给用户。【必须】在每个步骤/子任务开始时(不只是调用工具前),在回复正文的最前面单独写一行:⏵ 你在做的具体事情(不超过20字),然后换行继续正常回复。整轮回复只写一行 ⏵,不要重复。信息为主——让人一眼知道你在干什么,风格自然、可以带点俏皮。例:⏵ 修复登录页样式、⏵ 查一下报错原因、⏵ 给补丁跑个验证。切换任务时必须更新。'
90
+
91
+ /**
92
+ * Wire the working-activity plugin.
93
+ * @param ctx - Cordis context (agent loop + session services composed).
94
+ * @param config - Validated plugin config (schema defaults applied).
95
+ */
96
+ export function apply(ctx: Context, config: Config = {}): void {
97
+ const resolved: ResolvedConfig = {
98
+ phrases: config.phrases ?? true,
99
+ publish: config.publish ?? false,
100
+ tickMs: config.tickMs ?? 500,
101
+ publishIntervalMs: config.publishIntervalMs ?? 2000,
102
+ detailLimit: config.detailLimit ?? 40,
103
+ narrate: config.narrate ?? true,
104
+ customActions: config.customActions ?? {},
105
+ }
106
+ const trackers = new Map<Session, ActivityTracker>()
107
+ let activeSession: Session | undefined
108
+ let lastPublishedLine: string | undefined
109
+ let lastPublishedPhase: string | undefined
110
+ let lastPublishAt = 0
111
+
112
+ // Optional TUI seam: no TUI composed -> no slot, no error. The register()
113
+ // call is itself effect-owned, so fiber disposal unregisters the slot.
114
+ const prompt = ctx.get('tuiPrompt', false) as TuiPromptLike | undefined
115
+ const promptHandle = prompt?.register('activity', undefined)
116
+
117
+ // The `⏵` self-narration contract rides the stable system-prompt sections:
118
+ // injected when the systemPrompt service is composed (agent assemblies
119
+ // always mount it), removed with this fiber.
120
+ if (resolved.narrate) {
121
+ ctx.inject(['systemPrompt'], (promptCtx) => {
122
+ promptCtx.systemPrompt.section({
123
+ name: 'working-activity:narrate',
124
+ order: 60,
125
+ text: NARRATE_INSTRUCTION,
126
+ })
127
+ })
128
+ }
129
+
130
+ const trackerFor = (session: Session): ActivityTracker => {
131
+ let tracker = trackers.get(session)
132
+ if (tracker === undefined) {
133
+ tracker = new ActivityTracker(
134
+ { phrases: resolved.phrases, detailLimit: resolved.detailLimit, showIdle: false },
135
+ Date.now,
136
+ resolved.customActions,
137
+ )
138
+ trackers.set(session, tracker)
139
+ }
140
+ return tracker
141
+ }
142
+
143
+ /**
144
+ * Publish one rendered snapshot: TUI slot update + throttled session event.
145
+ * Callers snapshot the tracker state at event time and hand it here, so a
146
+ * burst of fast events (e.g. a synchronous tool call+result) cannot lose an
147
+ * intermediate phase; the append itself runs inside a microtask because the
148
+ * session's appending guard is still set while session/event callbacks run.
149
+ */
150
+ const publish = (session: Session, state: ActivityState): void => {
151
+ queueMicrotask(() => {
152
+ const line = state.phase === 'idle' ? undefined : state.line
153
+ promptHandle?.set(line)
154
+ if (!resolved.publish) return
155
+ const nowMs = Date.now()
156
+ const lineChanged = state.line !== lastPublishedLine
157
+ const phaseChanged = state.phase !== lastPublishedPhase
158
+ // Live phases republish on a throttle so elapsed times stay current;
159
+ // settled phases (idle/done) publish only when the line itself changes.
160
+ const liveThrottle = state.phase !== 'idle' && state.phase !== 'done'
161
+ && nowMs - lastPublishAt >= resolved.publishIntervalMs
162
+ if (!lineChanged && !phaseChanged && !liveThrottle) return
163
+ // Optional fields must be omitted (not undefined): session append rejects
164
+ // data JSON would discard, and `activity/status` is a lossless-JSON event.
165
+ const payload: ActivityStatusEvent = {
166
+ phase: state.phase,
167
+ line: state.line,
168
+ toolCount: state.toolCount,
169
+ turnElapsedMs: state.turnElapsedMs,
170
+ phaseStartedAt: state.phaseStartedAt,
171
+ ...(state.label === undefined ? {} : { label: state.label }),
172
+ ...(state.detail === undefined ? {} : { detail: state.detail }),
173
+ ...(state.phrase === undefined ? {} : { phrase: state.phrase }),
174
+ }
175
+ try {
176
+ session.append('activity/status', payload)
177
+ lastPublishedLine = state.line
178
+ lastPublishedPhase = state.phase
179
+ lastPublishAt = nowMs
180
+ } catch {
181
+ // Session closed or the append guard still held: drop this snapshot;
182
+ // the next tick retries the same line.
183
+ }
184
+ })
185
+ }
186
+
187
+ ctx.on('session/event', (session, event) => {
188
+ const tracker = trackerFor(session)
189
+ tracker.onSessionEvent(event)
190
+ activeSession = session
191
+ publish(session, tracker.render())
192
+ })
193
+
194
+ ctx.on('session/disposed', (session) => {
195
+ trackers.delete(session)
196
+ if (activeSession === session) activeSession = undefined
197
+ })
198
+
199
+ ctx.on('agent/status', ({ agent, status }) => {
200
+ const session = agent.session
201
+ const tracker = trackerFor(session)
202
+ tracker.onAgentStatus(status)
203
+ activeSession = session
204
+ publish(session, tracker.render())
205
+ })
206
+
207
+ // Continuous tick: elapsed times and the phrase rotation move on their own.
208
+ // A manual timer keeps this plugin free of the @cordisjs/plugin-timer mixin;
209
+ // the effect disposer clears it when this fiber unloads.
210
+ const tickTimer = setInterval(() => {
211
+ if (activeSession === undefined) return
212
+ const tracker = trackers.get(activeSession)
213
+ if (tracker === undefined) return
214
+ publish(activeSession, tracker.render())
215
+ }, resolved.tickMs)
216
+ ctx.effect(() => () => { clearInterval(tickTimer) }, 'working-activity tick timer')
217
+ }