dsh-working-activity 0.2.6 → 0.3.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/index.ts CHANGED
@@ -1,228 +1,278 @@
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 { registerActivityEventType } from './registration.js'
27
- import type { ActivityState } from './status.js'
28
- import type { ActivityStatusEvent } from './events.js'
29
- // Re-export the event type + SessionEventMap merge: the package root must carry
30
- // the declare-module side effect for consumers resolving the built d.ts.
31
- export type * from './events.js'
32
-
33
- export const name = 'working-activity'
34
-
35
- /** Configurable knobs; every key has a sane default. */
36
- export type Config = {
37
- /** Playful copy pool; false renders plain functional labels. */
38
- phrases?: boolean
39
- /** Append `activity/status` session events for UI consumers. Default OFF:
40
- * dsh-session's append() cannot mark events ignorable, and the resume
41
- * read path refuses logs containing unknown non-ignorable types every
42
- * appended snapshot makes the whole session unresumable. Re-enable only
43
- * for a log-replaying consumer on a harness that supports ignorable
44
- * appends. The live status line (prompt slot / session events) is
45
- * unaffected by this flag. */
46
- publish?: boolean
47
- /** Status render tick interval in ms. */
48
- tickMs?: number
49
- /** Minimum interval in ms between published events while the line is stable. */
50
- publishIntervalMs?: number
51
- /** Maximum displayed detail length (paths/commands/patterns). */
52
- detailLimit?: number
53
- /** Exact tool-name action-copy pools (case-insensitive match). */
54
- customActions?: Record<string, string[]>
55
- /** Inject the `⏵` self-narration contract into the system prompt and surface it. */
56
- narrate?: boolean
57
- }
58
-
59
- // Explicit annotation: the inferred z.dict output references cosmokit's
60
- // Dict through a pnpm-virtual path, which is not portable in declaration
61
- // emit (TS2883) when the dependency graph shifts. The global `Schemastery`
62
- // interface comes from schemastery's own d.ts (declare global).
63
- export const Config: Schemastery<Config> = z.object({
64
- phrases: z.boolean().default(true),
65
- publish: z.boolean().default(false),
66
- tickMs: z.number().step(50).min(100).max(5000).default(500),
67
- publishIntervalMs: z.number().step(500).min(500).max(30_000).default(2000),
68
- detailLimit: z.number().step(1).min(8).max(120).default(40),
69
- customActions: z.dict(z.array(z.string())).default({}),
70
- narrate: z.boolean().default(true),
71
- })
72
-
73
- /** Structural view of the TUI prompt service; the real type lives in dsh-tui. */
74
- interface TuiPromptLike {
75
- register(name: string, initialValue?: string): {
76
- set(value: string | undefined): void
77
- dispose(): void
78
- }
79
- }
80
-
81
- /** Resolved plugin configuration after schema defaults. */
82
- interface ResolvedConfig {
83
- phrases: boolean
84
- publish: boolean
85
- tickMs: number
86
- publishIntervalMs: number
87
- detailLimit: number
88
- customActions: Record<string, string[]>
89
- narrate: boolean
90
- }
91
-
92
- /** The self-narration contract injected into the system prompt (narrate on). */
93
- const NARRATE_INSTRUCTION =
94
- '[状态栏] 你有一个状态栏展示给用户。【必须】在每个步骤/子任务开始时(不只是调用工具前),在回复正文的最前面单独写一行:⏵ 你在做的具体事情(不超过20字),然后换行继续正常回复。整轮回复只写一行 ⏵,不要重复。信息为主——让人一眼知道你在干什么,风格自然、可以带点俏皮。例:⏵ 修复登录页样式、⏵ 查一下报错原因、⏵ 给补丁跑个验证。切换任务时必须更新。'
95
-
96
- /**
97
- * Wire the working-activity plugin.
98
- * @param ctx - Cordis context (agent loop + session services composed).
99
- * @param config - Validated plugin config (schema defaults applied).
100
- */
101
- export function apply(ctx: Context, config: Config = {}): void {
102
- // Register the event type BEFORE anything can publish or validate: the
103
- // strict read paths (resume seed validation, persistence load) refuse
104
- // logs with unknown non-ignorable types. Registration is unconditional —
105
- // it also protects READING logs written by an earlier publish:true era
106
- // in processes where publishing itself is off. See registration.ts.
107
- registerActivityEventType()
108
- const resolved: ResolvedConfig = {
109
- phrases: config.phrases ?? true,
110
- publish: config.publish ?? false,
111
- tickMs: config.tickMs ?? 500,
112
- publishIntervalMs: config.publishIntervalMs ?? 2000,
113
- detailLimit: config.detailLimit ?? 40,
114
- narrate: config.narrate ?? true,
115
- customActions: config.customActions ?? {},
116
- }
117
- const trackers = new Map<Session, ActivityTracker>()
118
- let activeSession: Session | undefined
119
- let lastPublishedLine: string | undefined
120
- let lastPublishedPhase: string | undefined
121
- let lastPublishAt = 0
122
-
123
- // Optional TUI seam: no TUI composed -> no slot, no error. The register()
124
- // call is itself effect-owned, so fiber disposal unregisters the slot.
125
- const prompt = ctx.get('tuiPrompt', false) as TuiPromptLike | undefined
126
- const promptHandle = prompt?.register('activity', undefined)
127
-
128
- // The `⏵` self-narration contract rides the stable system-prompt sections:
129
- // injected when the systemPrompt service is composed (agent assemblies
130
- // always mount it), removed with this fiber.
131
- if (resolved.narrate) {
132
- ctx.inject(['systemPrompt'], (promptCtx) => {
133
- promptCtx.systemPrompt.section({
134
- name: 'working-activity:narrate',
135
- order: 60,
136
- text: NARRATE_INSTRUCTION,
137
- })
138
- })
139
- }
140
-
141
- const trackerFor = (session: Session): ActivityTracker => {
142
- let tracker = trackers.get(session)
143
- if (tracker === undefined) {
144
- tracker = new ActivityTracker(
145
- { phrases: resolved.phrases, detailLimit: resolved.detailLimit, showIdle: false },
146
- Date.now,
147
- resolved.customActions,
148
- )
149
- trackers.set(session, tracker)
150
- }
151
- return tracker
152
- }
153
-
154
- /**
155
- * Publish one rendered snapshot: TUI slot update + throttled session event.
156
- * Callers snapshot the tracker state at event time and hand it here, so a
157
- * burst of fast events (e.g. a synchronous tool call+result) cannot lose an
158
- * intermediate phase; the append itself runs inside a microtask because the
159
- * session's appending guard is still set while session/event callbacks run.
160
- */
161
- const publish = (session: Session, state: ActivityState): void => {
162
- queueMicrotask(() => {
163
- const line = state.phase === 'idle' ? undefined : state.line
164
- promptHandle?.set(line)
165
- if (!resolved.publish) return
166
- const nowMs = Date.now()
167
- const lineChanged = state.line !== lastPublishedLine
168
- const phaseChanged = state.phase !== lastPublishedPhase
169
- // Live phases republish on a throttle so elapsed times stay current;
170
- // settled phases (idle/done) publish only when the line itself changes.
171
- const liveThrottle = state.phase !== 'idle' && state.phase !== 'done'
172
- && nowMs - lastPublishAt >= resolved.publishIntervalMs
173
- if (!lineChanged && !phaseChanged && !liveThrottle) return
174
- // Optional fields must be omitted (not undefined): session append rejects
175
- // data JSON would discard, and `activity/status` is a lossless-JSON event.
176
- const payload: ActivityStatusEvent = {
177
- phase: state.phase,
178
- line: state.line,
179
- toolCount: state.toolCount,
180
- turnElapsedMs: state.turnElapsedMs,
181
- phaseStartedAt: state.phaseStartedAt,
182
- ...(state.label === undefined ? {} : { label: state.label }),
183
- ...(state.detail === undefined ? {} : { detail: state.detail }),
184
- ...(state.phrase === undefined ? {} : { phrase: state.phrase }),
185
- }
186
- try {
187
- session.append('activity/status', payload)
188
- lastPublishedLine = state.line
189
- lastPublishedPhase = state.phase
190
- lastPublishAt = nowMs
191
- } catch {
192
- // Session closed or the append guard still held: drop this snapshot;
193
- // the next tick retries the same line.
194
- }
195
- })
196
- }
197
-
198
- ctx.on('session/event', (session, event) => {
199
- const tracker = trackerFor(session)
200
- tracker.onSessionEvent(event)
201
- activeSession = session
202
- publish(session, tracker.render())
203
- })
204
-
205
- ctx.on('session/disposed', (session) => {
206
- trackers.delete(session)
207
- if (activeSession === session) activeSession = undefined
208
- })
209
-
210
- ctx.on('agent/status', ({ agent, status }) => {
211
- const session = agent.session
212
- const tracker = trackerFor(session)
213
- tracker.onAgentStatus(status)
214
- activeSession = session
215
- publish(session, tracker.render())
216
- })
217
-
218
- // Continuous tick: elapsed times and the phrase rotation move on their own.
219
- // A manual timer keeps this plugin free of the @cordisjs/plugin-timer mixin;
220
- // the effect disposer clears it when this fiber unloads.
221
- const tickTimer = setInterval(() => {
222
- if (activeSession === undefined) return
223
- const tracker = trackers.get(activeSession)
224
- if (tracker === undefined) return
225
- publish(activeSession, tracker.render())
226
- }, resolved.tickMs)
227
- ctx.effect(() => () => { clearInterval(tickTimer) }, 'working-activity tick timer')
228
- }
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 { registerActivityEventType } from './registration.js'
27
+ import { setLangOverride, t } from './lang.js'
28
+ import { DEFAULT_PRESET } from './frames.js'
29
+ import type { ActivityState } from './status.js'
30
+ import type { ActivityStatusEvent } from './events.js'
31
+ // Re-export the event type + SessionEventMap merge: the package root must carry
32
+ // the declare-module side effect for consumers resolving the built d.ts.
33
+ export type * from './events.js'
34
+
35
+ export const name = 'working-activity'
36
+
37
+ /** Configurable knobs; every key has a sane default. */
38
+ export type Config = {
39
+ /** Playful copy pool; false renders plain functional labels. */
40
+ phrases?: boolean
41
+ /** Append `activity/status` session events for UI consumers. Default OFF:
42
+ * dsh-session's append() cannot mark events ignorable, and the resume
43
+ * read path refuses logs containing unknown non-ignorable types every
44
+ * appended snapshot makes the whole session unresumable. Re-enable only
45
+ * for a log-replaying consumer on a harness that supports ignorable
46
+ * appends. The live status line (prompt slot / session events) is
47
+ * unaffected by this flag. */
48
+ publish?: boolean
49
+ /** Status render tick interval in ms. */
50
+ tickMs?: number
51
+ /** Minimum interval in ms between published events while the line is stable. */
52
+ publishIntervalMs?: number
53
+ /** Maximum displayed detail length (paths/commands/patterns). */
54
+ detailLimit?: number
55
+ /** Exact tool-name action-copy pools (case-insensitive match). */
56
+ customActions?: Record<string, string[]>
57
+ /** Inject the `⏵` self-narration contract into the system prompt and surface it. */
58
+ narrate?: boolean
59
+ /** UI language: `auto` follows `DSH_TUI_LANG` `~/.dsh-tui/lang.json`
60
+ * OS locale zh; `zh`/`en` pin the copy directly. */
61
+ lang?: 'auto' | 'zh' | 'en'
62
+ /** Default frame preset name (informational for UI consumers; the TUI
63
+ * resolves the persisted `frames` choice itself). */
64
+ frames?: string
65
+ /** lively: full flourish (default) / minimal: functional labels only. */
66
+ mode?: 'lively' | 'minimal'
67
+ /** Per-feature switches; explicit values override `mode` defaults. */
68
+ features?: Record<string, boolean>
69
+ /** Extra thinking phrases appended to the base pool. */
70
+ customPhrases?: string[]
71
+ /** Show an estimated tokens/s prefix while streaming. */
72
+ showTokPerSec?: boolean
73
+ /** Work reminder after this many turn-hours (0 = off). */
74
+ workRemindAt?: number
75
+ }
76
+
77
+ // Explicit annotation: the inferred z.dict output references cosmokit's
78
+ // Dict through a pnpm-virtual path, which is not portable in declaration
79
+ // emit (TS2883) when the dependency graph shifts. The global `Schemastery`
80
+ // interface comes from schemastery's own d.ts (declare global).
81
+ export const Config: Schemastery<Config> = z.object({
82
+ phrases: z.boolean().default(true),
83
+ publish: z.boolean().default(false),
84
+ tickMs: z.number().step(50).min(100).max(5000).default(500),
85
+ publishIntervalMs: z.number().step(500).min(500).max(30_000).default(2000),
86
+ detailLimit: z.number().step(1).min(8).max(120).default(40),
87
+ customActions: z.dict(z.array(z.string())).default({}),
88
+ narrate: z.boolean().default(true),
89
+ lang: z.union(['auto', 'zh', 'en']).default('auto'),
90
+ frames: z.string().default(DEFAULT_PRESET),
91
+ mode: z.union(['lively', 'minimal']).default('lively'),
92
+ features: z.dict(z.boolean()).default({}),
93
+ customPhrases: z.array(z.string()).default([]),
94
+ showTokPerSec: z.boolean().default(false),
95
+ workRemindAt: z.number().min(0).max(24).default(0),
96
+ })
97
+
98
+ /** Structural view of the TUI prompt service; the real type lives in dsh-tui. */
99
+ interface TuiPromptLike {
100
+ register(name: string, initialValue?: string): {
101
+ set(value: string | undefined): void
102
+ dispose(): void
103
+ }
104
+ }
105
+
106
+ /** Resolved plugin configuration after schema defaults. */
107
+ interface ResolvedConfig {
108
+ phrases: boolean
109
+ publish: boolean
110
+ tickMs: number
111
+ publishIntervalMs: number
112
+ detailLimit: number
113
+ customActions: Record<string, string[]>
114
+ narrate: boolean
115
+ lang: 'auto' | 'zh' | 'en'
116
+ frames: string
117
+ mode: 'lively' | 'minimal'
118
+ features: Record<string, boolean>
119
+ customPhrases: string[]
120
+ showTokPerSec: boolean
121
+ workRemindAt: number
122
+ }
123
+
124
+ /**
125
+ * Wire the working-activity plugin.
126
+ * @param ctx - Cordis context (agent loop + session services composed).
127
+ * @param config - Validated plugin config (schema defaults applied).
128
+ */
129
+ export function apply(ctx: Context, config: Config = {}): void {
130
+ // Register the event type BEFORE anything can publish or validate: the
131
+ // strict read paths (resume seed validation, persistence load) refuse
132
+ // logs with unknown non-ignorable types. Registration is unconditional —
133
+ // it also protects READING logs written by an earlier publish:true era
134
+ // in processes where publishing itself is off. See registration.ts.
135
+ registerActivityEventType()
136
+ const resolved: ResolvedConfig = {
137
+ // `mode: minimal` renders functional labels only (pi extension parity).
138
+ phrases: config.phrases ?? config.mode !== 'minimal',
139
+ publish: config.publish ?? false,
140
+ tickMs: config.tickMs ?? 500,
141
+ publishIntervalMs: config.publishIntervalMs ?? 2000,
142
+ detailLimit: config.detailLimit ?? 40,
143
+ narrate: config.narrate ?? true,
144
+ lang: config.lang ?? 'auto',
145
+ customActions: config.customActions ?? {},
146
+ frames: config.frames ?? DEFAULT_PRESET,
147
+ mode: config.mode ?? 'lively',
148
+ features: config.features ?? {},
149
+ customPhrases: config.customPhrases ?? [],
150
+ showTokPerSec: config.showTokPerSec ?? false,
151
+ workRemindAt: config.workRemindAt ?? 0,
152
+ }
153
+ // A pinned plugin-level language beats the env/file chain; releasing it on
154
+ // dispose restores `auto` for any other composition in the process.
155
+ setLangOverride(resolved.lang)
156
+ ctx.effect(() => () => setLangOverride('auto'), 'working-activity lang override')
157
+ const trackers = new Map<Session, ActivityTracker>()
158
+ let activeSession: Session | undefined
159
+ let lastPublishedLine: string | undefined
160
+ let lastPublishedPhase: string | undefined
161
+ let lastPublishAt = 0
162
+
163
+ // Optional TUI seam: no TUI composed -> no slot, no error. The register()
164
+ // call is itself effect-owned, so fiber disposal unregisters the slot.
165
+ const prompt = ctx.get('tuiPrompt', false) as TuiPromptLike | undefined
166
+ const promptHandle = prompt?.register('activity', undefined)
167
+
168
+ // The `⏵` self-narration contract rides the stable system-prompt sections:
169
+ // injected when the systemPrompt service is composed (agent assemblies
170
+ // always mount it), removed with this fiber. The text is resolved at every
171
+ // assembly in the live language, so a `/lang` switch applies to the next
172
+ // turn without rebuilding the agent.
173
+ if (resolved.narrate) {
174
+ ctx.inject(['systemPrompt'], (promptCtx) => {
175
+ promptCtx.systemPrompt.section({
176
+ name: 'working-activity:narrate',
177
+ order: 60,
178
+ text: () => t('narrate-instruction'),
179
+ })
180
+ })
181
+ }
182
+
183
+ const trackerFor = (session: Session): ActivityTracker => {
184
+ let tracker = trackers.get(session)
185
+ if (tracker === undefined) {
186
+ tracker = new ActivityTracker(
187
+ {
188
+ phrases: resolved.phrases,
189
+ detailLimit: resolved.detailLimit,
190
+ showIdle: false,
191
+ features: resolved.features,
192
+ customPhrases: resolved.customPhrases,
193
+ showTokPerSec: resolved.showTokPerSec,
194
+ workRemindAt: resolved.workRemindAt,
195
+ },
196
+ Date.now,
197
+ resolved.customActions,
198
+ )
199
+ trackers.set(session, tracker)
200
+ }
201
+ return tracker
202
+ }
203
+
204
+ /**
205
+ * Publish one rendered snapshot: TUI slot update + throttled session event.
206
+ * Callers snapshot the tracker state at event time and hand it here, so a
207
+ * burst of fast events (e.g. a synchronous tool call+result) cannot lose an
208
+ * intermediate phase; the append itself runs inside a microtask because the
209
+ * session's appending guard is still set while session/event callbacks run.
210
+ */
211
+ const publish = (session: Session, state: ActivityState): void => {
212
+ queueMicrotask(() => {
213
+ const line = state.phase === 'idle' ? undefined : state.line
214
+ promptHandle?.set(line)
215
+ if (!resolved.publish) return
216
+ const nowMs = Date.now()
217
+ const lineChanged = state.line !== lastPublishedLine
218
+ const phaseChanged = state.phase !== lastPublishedPhase
219
+ // Live phases republish on a throttle so elapsed times stay current;
220
+ // settled phases (idle/done) publish only when the line itself changes.
221
+ const liveThrottle = state.phase !== 'idle' && state.phase !== 'done'
222
+ && nowMs - lastPublishAt >= resolved.publishIntervalMs
223
+ if (!lineChanged && !phaseChanged && !liveThrottle) return
224
+ // Optional fields must be omitted (not undefined): session append rejects
225
+ // data JSON would discard, and `activity/status` is a lossless-JSON event.
226
+ const payload: ActivityStatusEvent = {
227
+ phase: state.phase,
228
+ line: state.line,
229
+ toolCount: state.toolCount,
230
+ turnElapsedMs: state.turnElapsedMs,
231
+ phaseStartedAt: state.phaseStartedAt,
232
+ ...(state.label === undefined ? {} : { label: state.label }),
233
+ ...(state.detail === undefined ? {} : { detail: state.detail }),
234
+ ...(state.phrase === undefined ? {} : { phrase: state.phrase }),
235
+ }
236
+ try {
237
+ session.append('activity/status', payload)
238
+ lastPublishedLine = state.line
239
+ lastPublishedPhase = state.phase
240
+ lastPublishAt = nowMs
241
+ } catch {
242
+ // Session closed or the append guard still held: drop this snapshot;
243
+ // the next tick retries the same line.
244
+ }
245
+ })
246
+ }
247
+
248
+ ctx.on('session/event', (session, event) => {
249
+ const tracker = trackerFor(session)
250
+ tracker.onSessionEvent(event)
251
+ activeSession = session
252
+ publish(session, tracker.render())
253
+ })
254
+
255
+ ctx.on('session/disposed', (session) => {
256
+ trackers.delete(session)
257
+ if (activeSession === session) activeSession = undefined
258
+ })
259
+
260
+ ctx.on('agent/status', ({ agent, status }) => {
261
+ const session = agent.session
262
+ const tracker = trackerFor(session)
263
+ tracker.onAgentStatus(status)
264
+ activeSession = session
265
+ publish(session, tracker.render())
266
+ })
267
+
268
+ // Continuous tick: elapsed times and the phrase rotation move on their own.
269
+ // A manual timer keeps this plugin free of the @cordisjs/plugin-timer mixin;
270
+ // the effect disposer clears it when this fiber unloads.
271
+ const tickTimer = setInterval(() => {
272
+ if (activeSession === undefined) return
273
+ const tracker = trackers.get(activeSession)
274
+ if (tracker === undefined) return
275
+ publish(activeSession, tracker.render())
276
+ }, resolved.tickMs)
277
+ ctx.effect(() => () => { clearInterval(tickTimer) }, 'working-activity tick timer')
278
+ }