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.
package/src/index.ts ADDED
@@ -0,0 +1,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. */
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
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Package-owned `activity/status` snapshot invariants.
3
+ * @module dsh-working-activity/invariant
4
+ */
5
+
6
+ import type { Context } from '@deepseek-ai/cordis'
7
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
8
+ import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
9
+
10
+ /** Cordis companion plugin name. */
11
+ export const name = 'working-activity-invariant'
12
+ /** Service required before the companion can reserve package ownership. */
13
+ export const inject = ['invariants']
14
+
15
+ const PACKAGE_NAME = 'dsh-working-activity'
16
+ const PHASES = new Set(['idle', 'waiting', 'thinking', 'tool', 'done'])
17
+
18
+ /** Validate one published activity snapshot before it reaches the durable log. */
19
+ function validateStatus(data: unknown, fail: InvariantFailure): void {
20
+ const record = data as Record<string, unknown> | null
21
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) {
22
+ fail('activity/status data must be an object')
23
+ return
24
+ }
25
+ if (typeof record.phase !== 'string' || !PHASES.has(record.phase)) {
26
+ fail(`activity/status carries unknown phase ${JSON.stringify(record.phase)}`)
27
+ }
28
+ if (typeof record.line !== 'string' || record.line.length === 0) {
29
+ fail('activity/status line must be a non-empty string')
30
+ }
31
+ for (const key of ['toolCount', 'turnElapsedMs', 'phaseStartedAt']) {
32
+ const value = record[key]
33
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
34
+ fail(`activity/status ${key} must be a non-negative finite number`)
35
+ }
36
+ }
37
+ for (const key of ['label', 'detail', 'phrase']) {
38
+ if (record[key] !== undefined && typeof record[key] !== 'string') {
39
+ fail(`activity/status ${key} must be a string when present`)
40
+ }
41
+ }
42
+ }
43
+
44
+ /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
45
+ /** Validate the package-owned event shape and ignore unrelated events. */
46
+ function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
47
+ if (event.type === 'activity/status') validateStatus(event.data, fail)
48
+ }
49
+
50
+ /** Install validation for loaded and newly appended activity snapshots. */
51
+ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
52
+ for (const session of ctx.sessions.list()) {
53
+ for (const event of session.events) validateEvent(event, fail)
54
+ }
55
+ ctx.on('internal/dispatch', (_mode, eventName, args) => {
56
+ if (eventName !== 'session/event') return
57
+ const event = (args as [Session, SessionEvent])[1]
58
+ validateEvent(event, fail)
59
+ }, { global: true })
60
+ }, { inject: ['sessions'] })
61
+ /* jscpd:ignore-end */
62
+
63
+ /**
64
+ * Register the working-activity invariant companion.
65
+ * @param ctx - Cordis context carrying the invariant service.
66
+ * @returns the installed registration's disposer after setup succeeds.
67
+ */
68
+ export const apply = (ctx: Context): Promise<() => void> =>
69
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
package/src/phrases.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Copy pools for the working-activity status line: short, colloquial, playful
3
+ * Chinese fragments with deadpan English one-liners mixed in, matching the
4
+ * pi-working-activity tone. Everything here is pure data + pure pickers.
5
+ * @module @deepseek-ai/dsh-working-activity/phrases
6
+ */
7
+
8
+ /** A pool of copy fragments. */
9
+ export type PhrasePool = readonly string[]
10
+
11
+ /** Pick one random entry; repeated draws avoid the previous entry when possible. */
12
+ export function pickPhrase(entries: PhrasePool, previous?: string): string {
13
+ if (entries.length === 0) throw new Error('pickPhrase() requires a non-empty pool')
14
+ if (entries.length === 1) return entries[0] as string
15
+ let next = entries[Math.floor(Math.random() * entries.length)] as string
16
+ let guard = 0
17
+ while (next === previous && guard++ < 8) {
18
+ next = entries[Math.floor(Math.random() * entries.length)] as string
19
+ }
20
+ return next
21
+ }
22
+
23
+ /** Thinking phrases while the model works without a tool. */
24
+ export const THINKING_PHRASES: readonly string[] = [
25
+ '嗯…让我捋捋', '盘一下盘一下', '大脑转起来了', '思考.gif', '给我一秒', '脑子在冒烟',
26
+ '想呢想呢', '别催别催', '啾,让我想想', '让我琢磨下', '嗯…等一下哦', '正在盘逻辑',
27
+ '小脑瓜动一下', '嗯?哦…', '让我理理', '翻翻脑子', '回想中', '等一下下', '让我嗅嗅',
28
+ '脑内风暴中', '嗯…让我品品', '滴滴滴思考中', '稍等,在想', '盘明白了么', '挠头…',
29
+ '让子弹飞一会', '让我脑补一下', '加载中', '你说 我在听', '噢…是这样', '让我嚼一嚼',
30
+ '嗯…有点意思', '搓搓手想想', '等下,在想', '让我康康', '想好了告诉你', '脑子转圈圈',
31
+ '嗯…让我反应下', '等下下嘛', '思路加载中', '琢磨中', '嗯…让我拆一下', '盘,都可以盘',
32
+ '让我嗅探一下', '脑内跑火车', '嗯…让我缓一下', '滴滴,想呢', '思索.jpg', '嗯…有点东西',
33
+ '让我品', '小跑一下思路', '等下,有画面了', '让我咀嚼', '嗯…发会儿呆', '思考泡泡',
34
+ '脑电波传输中', '嗯…转转', '等下,盘好了', '让我回味', '滴滴滴', '思考的鱼',
35
+ '嗯…让我摸一下', '脑子在煮咖啡', '等下,我打个腹稿', '嗯…重启一下', '让我挠墙',
36
+ '嗯,来了来了', '脑子冒泡泡', '嗯…有点烫', '思考猫猫', '让我咕噜一下', '嗯…盘它',
37
+ '等下,我闪个思路', '脑子在蹦迪', '嗯…', '让我想想', '盘一下', '啾', 'lol', 'hm', 'oh',
38
+ 'ok', 'um', 'heh', 'uh', 'nah', 'mm', 'wow', 'nice', 'rgrg', 'okk', 'hhh', 'emm', 'emmm',
39
+ 'CPU烧了', '让我打个log看看', '先跑一下试试', '定位一下', '排查一下', '看看日志',
40
+ 'loading 99%', '让我捋一下逻辑',
41
+ ]
42
+
43
+ /** Tiered phrases when thinking runs long (elapsed >= threshold). */
44
+ export const THINKING_TIERS: readonly {
45
+ /** Minimum thinking ms for this tier. */
46
+ readonly atMs: number
47
+ readonly pool: readonly string[]
48
+ }[] = [
49
+ { atMs: 30_000, pool: ['嗯,让我细想想', '30秒了,还在盘', '等下,快好了', '别急,就快出结果了', '让我再捋一捋', '嗯…思路没断', '30秒,快了', '等等,有眉目了', '有点久…', '转圈圈…', '马上马上', '快了快了', '别走,就快好了', '在盘了呢', '还在定位', '快复现了'] },
50
+ { atMs: 60_000, pool: ['1分钟,还在想', '这题有点东西', '让我再钻研下', '嗯…问题不简单', '1分钟,别走开', '盘得有点深', '脑细胞在燃烧', '等等,快盘清了', '还在努力…', '这个有点绕…', '烧脑中…', '别走,快了', '一分钟了,再等等', '这题值得盘', '还在排查', '这个有点复杂'] },
51
+ { atMs: 300_000, pool: ['5分钟,大工程', '这把我得认真', '确实有点绕', '等等,我在修仙', '快好了,真的', '盘了一大圈', '别慌,在收尾', '给我一首歌的时间', '还没放弃…', '这题真的硬…', '我给跪了…', '憋大招中', '5分钟了,等值了', '快了,真快了', '这个需求很简单', '能跑就别动'] },
52
+ ]
53
+
54
+ /** Phrases shown while waiting for the first streamed token. */
55
+ export const WAITING_PHRASES: readonly string[] = [
56
+ '呼叫模型…', '模型在路上了', '等它开口…', '稍等,它有点慢', '模型加载中', '嗯…等它一下',
57
+ '它在组织语言', '等等我嘛', '模型醒了么', '等它伸懒腰', '它打了个哈欠', '模型:来了来了',
58
+ '等它出字', '别急,在等', '它磨蹭呢', '模型说等一下', '等它滴一声', '模型在咕噜',
59
+ '等它反应过来', '嗯…等它', '模型在喝水', '它说再等一下', '等它喘口气', '模型:快了快了',
60
+ '别急别急', '来了来了', '等它跑完', '还在排队', '马上出结果', '等它热身', '模型在酝酿',
61
+ '它翻了个身', '模型:马上', '等它开机', '它卡了一下', '模型在冥想', '等它眨个眼',
62
+ '它说稍等', '模型在查资料', '等它缓一缓', '模型在数数', '等它回神', '它终于动了',
63
+ ]
64
+
65
+ /** Tool-name patterns mapped to playful action verbs. */
66
+ export const ACTION_MAP: readonly {
67
+ readonly test: RegExp
68
+ readonly actions: readonly string[]
69
+ }[] = [
70
+ { test: /^(read|read_file|cat)$/i, actions: ['翻翻文档', '让我康康', '读一下', '看一眼', '翻阅中', '读读看', '翻翻', '看看', '瞄一眼', '康康', '翻一页'] },
71
+ { test: /^(write|write_file|create_file)$/i, actions: ['写写写', '下笔中', '码字呢', '写一段', '记录一下', '写一下', '记下来', '落笔', '开写', '存个文件'] },
72
+ { test: /^(edit|edit_file|str_replace|apply_patch|search_replace)$/i, actions: ['改改', '修修补补', '润色一下', '编辑中', '调整调整', '改一改', '修一下', '改两行', '调一下', '补一刀'] },
73
+ { test: /^(bash|shell|run|exec|powershell|cmd)$/i, actions: ['跑个命令', 'bash一下', '敲敲指令', '命令行走起', '执行一下', '敲回车', '跑一下', '敲个命令', '跑命令', '使唤终端'] },
74
+ { test: /^(grep|rg|search|search_in_files)$/i, actions: ['搜搜东西', 'grep 一下', '找找匹配', '关键词走你', '过滤中', '搜搜看', '搜一下', '找找', '扫一眼', '挖一挖'] },
75
+ { test: /^(find|glob)$/i, actions: ['找找文件', '找一下', '寻宝中', '找啊找', '文件在哪', '查找中', '搜搜目录'] },
76
+ { test: /^(ls|list_dir|list)$/i, actions: ['列个清单', '看看目录', 'ls 看一眼', '瞄一下文件', '目录走起', '列出来', '列一下', '瞟一眼', '翻翻'] },
77
+ { test: /^(web_search|search_web|brave|tavily|exa)$/i, actions: ['网上搜搜', '搜一下', '网络冲浪', '查找资料', '上网瞄瞄', '上网搜搜', '查查', '搜一圈', '打听一下'] },
78
+ { test: /^(web_fetch|fetch|fetch_content)$/i, actions: ['抓个页面', '拉取一下', 'fetch 中', '扒拉网页', '取点内容', '抓取资料', '扒一下', '打开看看'] },
79
+ { test: /^(mcp)/i, actions: ['mcp 连一下', '调个服务', '接个工具', 'mcp 走你', '调接口', '连一下', '喊外援', '接一下'] },
80
+ { test: /^(subagent|agent|task)$/i, actions: ['派个小弟', '小助手出动', '支个 agent', '让小弟跑腿', '代理干活', '子任务起飞', '分个任务', '交给小弟', '派出去'] },
81
+ { test: /^(todo|manage_todo_list)$/i, actions: ['列个待办', '写个清单', 'todo 安排', '记一下', '待办走起', '清单一下', '记个待办', '打个勾'] },
82
+ { test: /^(browser|chrome|playwright)/i, actions: ['开个浏览器', '浏览器跑腿', '网页操作', '浏览器干活', '开网页', '点点页面'] },
83
+ { test: /^(git|gh|github)/i, actions: ['git 操作', '提交一下', '版本控制', 'git 走你', '提交代码', '管个仓库', 'git 一下'] },
84
+ { test: /^(ask_user_question|ask)$/i, actions: ['提问中', '问一个问题', 'ask 一下', '请教一下', '问问看', '问你个事', '确认一下'] },
85
+ { test: /^(goal_complete|goal_blocked)$/i, actions: ['定个目标', '设定目标', 'goal 设置', '目标走起', '规划一下', '更新进度'] },
86
+ { test: /^(todo_write)$/i, actions: ['记个待办', '划个清单', '打个勾'] },
87
+ ]
88
+
89
+ /** Fallback verbs for unknown tools. */
90
+ export const FALLBACK_ACTIONS: readonly string[] = ['干活', '调用', '整一下', '搞一下', '动动手', '备选方案', '换条路']
91
+
92
+ /** Tool failure phrases, replacing a bare ✗. */
93
+ export const FAIL_PHRASES: readonly string[] = [
94
+ '翻车了', '哎呀', '掉了', '没跑通', '摔了一跤', '再来一次', '这不对', '出岔子了', '不灵了',
95
+ '坏消息', '权限不对?', '连不上?', '404了', '不太对', '有点问题', '再看看', '没接住', '漏了',
96
+ '我本地能跑啊', '昨天还能跑', '重启试试', '清一下缓存', '删了重装', '你刷新一下', '环境问题',
97
+ '少了个分号', '拼错了', '没保存', '又不是不能用', '绷不住了', '难绷', '卒', '裂开',
98
+ '血压上来了', '缓存害我', '再给我一次机会', '这波大意了', '手滑', '回滚重来', '换个姿势',
99
+ ]
100
+
101
+ /** Turn-completion phrases. */
102
+ export const DONE_PHRASES: readonly string[] = [
103
+ '交差!', '搞定,下一个', '好了,收工', '完成啦', '交作业', '结束,完美', '完工咯', '搞定啦',
104
+ '任务完成', '好了,歇会儿', '搞定', '收工', '妥了', '完事', '交差', '齐活', '拿下', '收工!',
105
+ '搞定收工', '收!', '完事!', '下一题', '能跑!', '没报错', '过了', '上线!', '稳了', '6',
106
+ '完工!', '完美收场', '这波不亏', '一次过', '收工摸鱼', '漂亮', '全绿', '干净利落',
107
+ '手到擒来', '水到渠成', '下班!', '歇口气', '交接完成', '工单关闭', '收尾完毕',
108
+ ]
109
+
110
+ /** Night-owl phrases mixed in between 00:00 and 06:00 local time. */
111
+ export const NIGHT_PHRASES: readonly string[] = [
112
+ '修仙中…', '深夜冒泡', '你也是夜猫子呀', '月亮不睡我不睡', '夜里脑子慢,谅解', '晚安?还早呢',
113
+ '深夜盘东西', '熬夜冠军上线', '困了,但能行', '过了零点照样肝', '夜猫子出没', '深夜档营业',
114
+ '星星都睡了', '凌晨还在盘', '深夜上线', '凌晨部署', '通宵了',
115
+ ]
116
+
117
+ /** Common git tool names / bash commands containing `git `. */
118
+ export const GIT_TOOL_RE = /^(?:git|git_diff|git_commit|git_push|git_pull|git_checkout|git_branch|git_merge|git_rebase|github|gh)$/i
119
+
120
+ /** Detect the 00:00–06:00 night window (local time). */
121
+ export function isNight(hour: number): boolean {
122
+ return hour >= 0 && hour < 6
123
+ }
124
+
125
+ /**
126
+ * Pick a thinking phrase appropriate for the elapsed thinking time.
127
+ * @param elapsedMs - Milliseconds spent thinking in the current phase.
128
+ * @param previous - Previously shown phrase, to avoid repeats.
129
+ * @param night - Mix night-owl copy into the pool.
130
+ */
131
+ export function thinkingPhrase(elapsedMs: number, previous?: string, night = false): string {
132
+ let pool: readonly string[] = THINKING_PHRASES
133
+ for (const tier of THINKING_TIERS) {
134
+ if (elapsedMs >= tier.atMs) {
135
+ pool = tier.pool
136
+ break
137
+ }
138
+ }
139
+ if (night && pool === THINKING_PHRASES) {
140
+ return pickPhrase([...pool, ...NIGHT_PHRASES], previous)
141
+ }
142
+ return pickPhrase(pool, previous)
143
+ }
144
+
145
+ /**
146
+ * Map a tool name to a playful action verb.
147
+ * @param toolName - Registry tool name (unqualified).
148
+ * @param custom - Exact-name custom action pools, matched case-insensitively.
149
+ */
150
+ export function actionFor(toolName: string, custom?: Readonly<Record<string, readonly string[]>>): string {
151
+ const normalized = toolName.trim().toLowerCase()
152
+ const customPool = custom?.[normalized]
153
+ if (customPool !== undefined && customPool.length > 0) return pickPhrase(customPool)
154
+ for (const { test, actions } of ACTION_MAP) {
155
+ if (test.test(normalized)) return pickPhrase(actions)
156
+ }
157
+ return pickPhrase(FALLBACK_ACTIONS)
158
+ }
159
+
160
+ /** Whether a tool is a git operation (name match, or a shell command containing `git `). */
161
+ export function isGitTool(toolName: string, args?: Readonly<Record<string, unknown>>): boolean {
162
+ if (GIT_TOOL_RE.test(toolName.trim())) return true
163
+ if (/^(?:bash|shell|cmd|powershell|pwsh)$/i.test(toolName.trim())) {
164
+ const command = args?.command ?? args?.cmdline
165
+ return typeof command === 'string' && /\bgit\s+/.test(command)
166
+ }
167
+ return false
168
+ }
169
+
170
+ /** Format milliseconds as a compact human duration (`1m23s`). */
171
+ export function fmtDuration(ms: number): string {
172
+ if (ms < 1000) return '0s'
173
+ const total = Math.floor(ms / 1000)
174
+ if (total < 60) return `${total}s`
175
+ const minutes = Math.floor(total / 60)
176
+ const seconds = total % 60
177
+ if (minutes < 60) return `${minutes}m${seconds}s`
178
+ const hours = Math.floor(minutes / 60)
179
+ return `${hours}h${minutes % 60}m`
180
+ }