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/invariant.ts CHANGED
@@ -1,69 +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))
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/lang.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Language resolution + copy dictionary for the working-activity plugin.
3
+ *
4
+ * The plugin follows the dsh-tui UI language without importing it: the same
5
+ * chain dsh-tui resolves (`DSH_TUI_LANG` env → `~/.dsh-tui/lang.json` → OS
6
+ * locale → zh) is read here directly, so a `/lang en|zh` switch (or the
7
+ * /settings language pick) hot-swaps this plugin's narration and status-line
8
+ * copy on the next render tick.
9
+ *
10
+ * Resolution order:
11
+ * 1. `setLangOverride()` — a plugin-level `lang: zh|en` config key
12
+ * (cordis.yml) or an explicit test pin.
13
+ * 2. `DSH_TUI_LANG` env var — pinned at process start.
14
+ * 3. `~/.dsh-tui/lang.json` — the persisted dsh-tui choice, mtime-cached
15
+ * so the per-tick status render never re-reads an unchanged file.
16
+ * 4. OS locale guess (`LC_ALL` / `LC_MESSAGES` / `LANG`); POSIX/C means
17
+ * "no locale selected" and maps to English.
18
+ * 5. `zh` — the original hard-coded language.
19
+ *
20
+ * The dictionary is a flat key → per-language string map; `t(key, params)`
21
+ * substitutes `{{name}}` placeholders. Missing keys render the key itself so
22
+ * a typo is visible instead of silently blank.
23
+ * @module @deepseek-ai/dsh-working-activity/lang
24
+ */
25
+
26
+ import { readFileSync, statSync } from 'node:fs'
27
+ import { homedir } from 'node:os'
28
+ import { join } from 'node:path'
29
+
30
+ export type Lang = 'zh' | 'en'
31
+
32
+ /** The languages shipped with the plugin, in display order. */
33
+ export const LANGS = ['zh', 'en'] as const
34
+
35
+ /** The dsh-tui prefs file this plugin mirrors (shared language contract). */
36
+ const LANG_FILE = join(homedir(), '.dsh-tui', 'lang.json')
37
+
38
+ const dict = {
39
+ // ── ⏵ self-narration contract (system prompt, /lang aware) ──────────
40
+ 'narrate-instruction': {
41
+ zh: '[状态栏] 你有一个状态栏展示给用户。【必须】在每个步骤/子任务开始时(不只是调用工具前),在回复正文的最前面单独写一行:⏵ 你在做的具体事情(不超过20字),然后换行继续正常回复。整轮回复只写一行 ⏵,不要重复。信息为主——让人一眼知道你在干什么,风格自然、可以带点俏皮。例:⏵ 修复登录页样式、⏵ 查一下报错原因、⏵ 给补丁跑个验证。切换任务时必须更新。',
42
+ en: '[Status line] You have a status line visible to the user. [Required] At the start of each step or subtask (not only before tool calls), write exactly one standalone line at the very beginning of your response: ⏵ a concrete description of what you are doing (20 words max), then continue with the normal response on the next line. Write only one ⏵ line per response and do not repeat it. Prioritize information so the user can understand the current work at a glance; keep the style natural and optionally playful. Examples: ⏵ Fixing the login page styles, ⏵ Investigating the error, ⏵ Running validation for the patch. Update it when the task changes.',
43
+ },
44
+
45
+ // ── status-line structural copy ─────────────────────────────────────
46
+ /** Plain (non-playful) phase labels. */
47
+ 'waiting-label': { zh: '等待模型响应', en: 'Waiting for model' },
48
+ 'thinking-label': { zh: '思考中', en: 'Thinking' },
49
+ /** Elapsed suffix: zh glues the char, en keeps a space. */
50
+ 'line-elapsed': { zh: '总{{elapsed}}', en: 'total {{elapsed}}' },
51
+ /** Plain-mode completion prefix (playful pools pick their own). */
52
+ 'done-prefix': { zh: '搞定 ✓', en: 'Finished' },
53
+ /** Turn-completion summary. */
54
+ 'done-summary': {
55
+ zh: '{{tools}} · 想{{thinking}} 干{{tooling}}',
56
+ en: '{{tools}} · thought {{thinking}} worked {{tooling}}',
57
+ },
58
+ 'tool-count-one': { zh: '{{count}} 工具', en: '{{count}} tool' },
59
+ 'tool-count-many': { zh: '{{count}} 工具', en: '{{count}} tools' },
60
+ /** Subagent count in the done summary. */
61
+ 'subagent-count': { zh: '子代理 {{count}} 个', en: '{{count}} subagents' },
62
+ /** Work-reminder copy after `workRemindAt` turn-hours. */
63
+ 'work-remind': { zh: '已连续工作 {{hours}} 小时,歇会儿?', en: 'Worked {{hours}}h straight — take a break?' },
64
+ } as const
65
+
66
+ export type I18nKey = keyof typeof dict
67
+ export type I18nParams = Record<string, string | number>
68
+
69
+ /** Explicit pin set by plugin config or tests; `auto` restores the chain. */
70
+ let override: Lang | 'auto' = 'auto'
71
+
72
+ /** Force (or release) the active language; `auto` re-enables the chain. */
73
+ export function setLangOverride(lang: Lang | 'auto'): void {
74
+ override = lang
75
+ }
76
+
77
+ /** The currently active language. */
78
+ export function langNow(): Lang {
79
+ if (override !== 'auto') return override
80
+ const env = process.env.DSH_TUI_LANG
81
+ if (env === 'zh' || env === 'en') return env
82
+ return readLangFile() ?? detectLocaleLang()
83
+ }
84
+
85
+ /** Translate a dictionary key, substituting `{{name}}` placeholders. */
86
+ export function t(key: I18nKey, params: I18nParams = {}): string {
87
+ const entry = dict[key] as { zh: string; en: string } | undefined
88
+ const template = entry?.[langNow()] ?? key
89
+ return template.replace(/\{\{(\w+)\}\}/g, (match, name: string) =>
90
+ name in params ? String(params[name]) : match,
91
+ )
92
+ }
93
+
94
+ /** Is a value a valid shipped language code? */
95
+ export function isLang(value: unknown): value is Lang {
96
+ return value === 'zh' || value === 'en'
97
+ }
98
+
99
+ /**
100
+ * Read the persisted dsh-tui language choice, mtime-cached so the per-tick
101
+ * status render never re-reads an unchanged file. `undefined` when the file
102
+ * is absent or holds no valid `{ lang }` value.
103
+ */
104
+ export function readLangFile(): Lang | undefined {
105
+ let mtimeMs: number
106
+ try {
107
+ mtimeMs = statSync(LANG_FILE).mtimeMs
108
+ } catch {
109
+ return undefined
110
+ }
111
+ if (cachedMtime === mtimeMs) return cachedLang
112
+ try {
113
+ const parsed: unknown = JSON.parse(readFileSync(LANG_FILE, 'utf8'))
114
+ const lang = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
115
+ ? (parsed as Record<string, unknown>).lang
116
+ : undefined
117
+ cachedLang = isLang(lang) ? lang : undefined
118
+ } catch {
119
+ cachedLang = undefined
120
+ }
121
+ cachedMtime = mtimeMs
122
+ return cachedLang
123
+ }
124
+
125
+ let cachedMtime = -1
126
+ let cachedLang: Lang | undefined
127
+
128
+ /**
129
+ * Guess the language from the OS locale (`LC_ALL`, `LC_MESSAGES`, `LANG`),
130
+ * defaulting to `zh`. POSIX/C means "no locale selected" and conventionally
131
+ * maps to English (what CI runners report). An absent locale variable
132
+ * (typical on Windows) defaults to `zh`.
133
+ */
134
+ export function detectLocaleLang(): Lang {
135
+ const raw =
136
+ process.env.LC_ALL ||
137
+ process.env.LC_MESSAGES ||
138
+ process.env.LANG ||
139
+ ''
140
+ const locale = raw.split('.')[0]?.toLowerCase() ?? ''
141
+ if (locale.startsWith('zh')) return 'zh'
142
+ if (locale.startsWith('en')) return 'en'
143
+ if (locale === 'c' || locale === 'posix') return 'en'
144
+ return 'zh'
145
+ }