@nolit/dsh-notify 0.0.0-stage → 0.0.1-rc.3

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +388 -0
  2. package/LICENSE +21 -0
  3. package/PROVENANCE.md +96 -0
  4. package/README.md +298 -2
  5. package/cordis.patch.yml +19 -0
  6. package/docs/2026-10-20-dsh-notify-design.md +335 -0
  7. package/docs/2026-10-20-final-status.md +125 -0
  8. package/docs/2026-10-23-asset-policy.md +170 -0
  9. package/docs/2026-10-23-deployment-strategy-review.md +530 -0
  10. package/docs/2026-10-23-headless-nonintrusive-ui-architecture.md +579 -0
  11. package/docs/2026-10-23-hybrid-dual-mode-design.md +955 -0
  12. package/docs/2026-10-23-timezone-global-report.md +204 -0
  13. package/docs/2026-10-23-web-notify-plugin-spec.md +454 -0
  14. package/docs/2026-10-23-web-plugin-mount.md +111 -0
  15. package/docs/2026-10-23-windows-widget-poc-report.md +312 -0
  16. package/docs/2026-10-24-linux-desktop-verification.md +134 -0
  17. package/docs/BOOT-CARD.md +176 -0
  18. package/docs/RECOVERY-NOTES.md +111 -0
  19. package/docs/WORK-PROTOCOL.md +155 -0
  20. package/docs/deepseek-peak-spec.md +370 -0
  21. package/docs/dsh-harness-notify-spec.md +149 -0
  22. package/icon.svg +6 -0
  23. package/lib/client.js +2172 -0
  24. package/lib/client.src.js +1963 -0
  25. package/lib/index.js +13 -0
  26. package/lib/locale-table.js +215 -0
  27. package/locale/en.json +104 -0
  28. package/locale/ko.json +104 -0
  29. package/market/icons/dsh-notify.svg +6 -0
  30. package/market/index.json +77 -0
  31. package/package.json +80 -4
  32. package/scripts/build-client.mjs +41 -0
  33. package/scripts/gen-locale.mjs +36 -0
  34. package/test/client.test.mjs +601 -0
  35. package/test/locale.test.mjs +114 -0
  36. package/test/manifest.test.mjs +115 -0
  37. package/test/peak.test.mjs +333 -0
  38. package/test/typography.test.mjs +85 -0
package/README.md CHANGED
@@ -1,3 +1,299 @@
1
- # Temporary Holding Version
1
+ # Notify for Chrome
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <!-- skeleton:
4
+ version: 2.0
5
+ kind: readme
6
+ status: active
7
+ updated: 2026-11-05
8
+ summary: Operator manual and behavior contract for Notify for Chrome (Korean: 알림 for 크롬) — Chrome/Web Notification only, silent in-app toast fallback, native DSH settings rows and typography, Windows/install-mode notification branches.
9
+ -->
10
+
11
+ > Status: active · Updated: 2026-11-06 · Package: `@nolit/dsh-notify` v`0.0.1-rc.3` · Korean display name: **알림 for 크롬** · Parent: `README.md` in the `dsh-plugins` workspace
12
+
13
+ **Notify for Chrome** turns DeepSeek Harness events into **Chrome notifications** (the Web
14
+ Notification API, `new Notification(...)`). It has no native app, no ports, no IPC, no file queue
15
+ and no system notifier. When Chrome cannot show a notification, the plugin falls back to a
16
+ **silent in-app toast**.
17
+
18
+ ## 1. Behavior
19
+
20
+ | DSH event | Channel |
21
+ |---|---|
22
+ | `api-session/status` (`running=false`) | Chrome notification "Turn complete", root sessions only, 5 s cooldown |
23
+ | `api-session/error` | Chrome notification "Turn failed", message truncated to 160 chars |
24
+ | `approval/request` | Chrome notification "Approval needed"; `next()` passes through immediately |
25
+ | `user-questions/request` | Chrome notification "Question waiting"; `next()` passes through immediately |
26
+
27
+ - **Chrome first.** The plugin calls the Notification API. A granted permission and a secure
28
+ context are the only prerequisites.
29
+ - **Silent toast fallback.** If the permission is denied/pending, the origin is not a secure
30
+ context, the Notification API is unavailable, or the constructor/`onerror` fails, the event is
31
+ shown as the **native DSH toast** and nothing else. There is no sound, no second notification
32
+ channel, and no title-bar marker.
33
+ - **One tab delivers.** Multiple tabs elect a leader (Web Locks, localStorage lease fallback) so an
34
+ event is not announced once per tab. A focused tab always delivers.
35
+ - **No sound engine.** The plugin never synthesizes audio, never ships or stores audio, and never
36
+ uses `<audio>`/Web Audio/IndexedDB. When the OS notification has a sound, it is the **operating
37
+ system's own** notification sound.
38
+
39
+ ## 2. Windows and install-environment behavior
40
+
41
+ **Which OS does the environment card report?** The **browser's** OS — the machine running the tab
42
+ that shows the notification — not the OS running the `dsh` host process. The two are independent:
43
+ a Windows Chrome can be pointed at a DSH server on Linux (the default remote setup), and the
44
+ notification rules that matter (Action Center persistence, `requireInteraction` support, the
45
+ installed-app branch) are properties of the browser's OS.
46
+
47
+ The sensitivity is per-platform, measured:
48
+
49
+ | Browser OS | Installed app | Behaviour |
50
+ |---|---|---|
51
+ | Windows | yes | `requireInteraction: true` for every kind; the app window and Action Center keep them |
52
+ | Windows | no | `requireInteraction` follows the setting, approval/question only |
53
+ | Linux | either | same as the Windows browser tab |
54
+ | macOS | either | `requireInteraction` is not honoured by Chrome, so it stays `false` |
55
+ | Android / other | either | `silent: false`, unique `tag`; no persistence |
56
+
57
+ Because a value like "Windows" next to a Linux host looks wrong, the row is labelled
58
+ **Browser OS** (브라우저 OS) and carries that explanation inline.
59
+
60
+ The Chrome notification parameters branch on the **runtime platform** and the **install mode**.
61
+ Platform detection order: DSH's `document.documentElement.dataset.platform` (the native shell sets
62
+ it; native code also tests it for `darwin`), then `navigator.userAgentData.platform`, then
63
+ `navigator.platform`, then `navigator.userAgent`. Install mode is `installed` when the page runs in
64
+ a standalone/minimal-ui/fullscreen display mode (or `navigator.standalone`), otherwise `browser`.
65
+
66
+ | Environment | `requireInteraction` | `silent` | `tag` |
67
+ |---|---|---|---|
68
+ | **Windows, installed app** | `true` for every kind — the Action Center keeps the alert while the app window is minimized, so a completion cannot be auto-dismissed | `false` (the OS plays the sound) | unique per event |
69
+ | **Windows, browser tab** | follows the "Keep approval alerts open" setting; approval/question only | `false` | unique per event |
70
+ | **macOS** | `false` — Chrome does not honour `requireInteraction`, so claiming it would misrepresent the behavior | `false` | unique per event |
71
+ | **Linux / other** | follows the setting; approval/question only | `false` | unique per event |
72
+
73
+ - **Why a unique tag on Windows:** Windows replaces an existing notification that reuses a `tag`,
74
+ which silently drops repeated events. Every event gets a fresh tag so completions accumulate in
75
+ the Action Center instead of being replaced.
76
+ - **Permission guidance is platform-specific:** a Windows profile points at Chrome's site settings
77
+ and **Windows Settings → System → Notifications**; other platforms point at the browser's site
78
+ settings. The guidance is shown next to the status row and is the only place a blocked
79
+ permission is explained.
80
+ - Nothing else is Windows-specific: event filtering, cooldowns, tab leadership, locale and the
81
+ toast fallback are generic.
82
+
83
+ ## 3. Install
84
+
85
+ The package name stays `@nolit/dsh-notify`; only the **display identity** changed to
86
+ "Notify for Chrome" / "알림 for 크롬". Renaming the npm package would break the live profile's
87
+ `link:` dependency.
88
+
89
+ Local checkout (the live profile already links it):
90
+
91
+ ```bash
92
+ dsh plugin --profile web add "link:/path/to/dsh-plugins/dsh-notify"
93
+ ```
94
+
95
+ From npm after publishing:
96
+
97
+ ```bash
98
+ dsh plugin --profile web add @nolit/dsh-notify
99
+ ```
100
+
101
+ The package must be listed in the profile's `dsh.profile.bundles`. Reload the DSH page after
102
+ installing. If `lib/client.js` has a syntax error only this plugin is disabled; the DSH core keeps
103
+ working.
104
+
105
+ ## 4. Settings
106
+
107
+ **Settings → Notify for Chrome** (English; the label is **알림 for 크롬** when the host locale is
108
+ Korean) renders native DSH settings surfaces only:
109
+
110
+ - a native `Tag` for the Chrome-notification status, native `Button` actions, native `Switch`
111
+ toggles and a native `Input` for the cooldown;
112
+ - the section navigation icon is a **DSH icon** (`IconAlarmClockOutlineRegular`), registered through
113
+ `settings.section.icon`; the plugin ships no icon set of its own;
114
+ - typography comes from the live DSH tokens `--dsh-content-font-size` /
115
+ `--dsh-content-font-size-secondary` and their `--dsh-content-font-delta*` line-height deltas;
116
+ colours and surfaces come from `--dsw-*` tokens. The plugin **injects no stylesheet**, sets **no
117
+ font-family** and hardcodes **no pixel font size**.
118
+
119
+ The page is grouped into four cards. The two owner-requested categories (2026-11-06) are the
120
+ **DeepSeek peak timer** and **notifications**, in that order.
121
+
122
+ **알림 조건 (notification rows)**
123
+
124
+ | Row | Default | Meaning |
125
+ |---|---|---|
126
+ | Turn complete / Approval needed / Question waiting / Turn error | on | which events notify |
127
+ | Include subagents | off | also notify for subagent complete/error |
128
+ | Hide completion alerts while focused | off | suppress completion while the DSH tab is focused |
129
+ | Keep approval alerts open | on | `requireInteraction` for approval/question where supported (Windows/Linux) |
130
+ | Completion cooldown | 5 s | per-session grouping window |
131
+
132
+ **딥시크 피크 타이머 (peak timer rows)**
133
+
134
+ | Row | Default | Meaning |
135
+ |---|---|---|
136
+ | Show peak countdown chip | on | 7-segment `HH:MM:SS` chip in the Session header: green off-peak, red at peak |
137
+ | Announce peak changes | on | Chrome notification + `setAppBadge` when the billing window flips |
138
+ | Show timer on other providers | **off** | also render the chip when the active route is *not* an official DeepSeek route |
139
+
140
+ ### The peak timer only trusts official DeepSeek routes by default
141
+
142
+ The peak/off-peak clock is a property of **DeepSeek's own API billing**
143
+ ([pricing footnote](https://api-docs.deepseek.com/quick_start/pricing)). DeepSeek publishes the
144
+ off-peak discount for its own API; **a different service serving the same model may bill
145
+ differently**, so the timer is a *reference*, not a fact, on any other route.
146
+
147
+ The plugin therefore gates on the **provider id**, never on a model name:
148
+
149
+ | Route | Chip by default |
150
+ |---|---|
151
+ | `deepseek-official` (official API-key route) | shown |
152
+ | `deepseek-account` (official account route) | shown |
153
+ | everything else (OpenCode, DeepInfra, Antigravity, any proxy/gateway) | **hidden** |
154
+
155
+ Those two ids are the ones the official DSH packages register. **Provider ids differ between
156
+ installations** — if your setup exposes the official DeepSeek route under a different id, turn on
157
+ **Show timer on other providers** and the chip appears on every route. The default stays
158
+ conservative because a wrong "50% off" claim is worse than a hidden widget.
159
+
160
+ The live selection is read from `[data-provider-id][data-model-id]` on the model selector trigger,
161
+ which is the same path DeepInfra's cost chip uses. Nothing is guessed from the model name.
162
+
163
+ The settings card carries the caveat inline, in the owner's wording (2026-11-06):
164
+
165
+ > 딥시크는 공식 모델 API에 대해서만 할인을 명시합니다. 같은 모델이라도 다른 제공자는 서비스 요금이
166
+ > 다를 수 있으며 보장하지 않습니다. 그 경우 이 타이머는 참고 사항일 뿐입니다.
167
+ >
168
+ > *DeepSeek states this discount only for its official model API. A different provider may charge
169
+ > differently for the same model, and that is not guaranteed. In that case this timer is a reference
170
+ > only.*
171
+
172
+ Preferences persist in the browser's `localStorage` under `dsh-notify-web:settings` (version 2).
173
+ They are browser preferences, not Host configuration: the plugin's Host half is intentionally a
174
+ no-op and exposes no settings namespace. No audio file is read, stored or referenced.
175
+
176
+ Guidance rules:
177
+
178
+ - **Permission pending** → the Enable button is shown.
179
+ - **Denied / insecure / unsupported** → the Enable button is **hidden** (a capability that is gone
180
+ is not shown as a broken control); the row explains what to change, and the test action still
181
+ exercises the toast fallback.
182
+
183
+ ## 5. Localization
184
+
185
+ English is the default. Korean is used **only** while the host locale (`ctx.locale.active`) is
186
+ Korean; a locale change switches the next notification and the settings strings live.
187
+
188
+ - Source of truth: [`locale/en.json`](locale/en.json) and [`locale/ko.json`](locale/ko.json).
189
+ - `scripts/gen-locale.mjs` bakes them into `lib/locale-table.js`; `scripts/build-client.mjs` inlines
190
+ that table into `lib/client.js` (the browser loader cannot import JSON).
191
+ - No hardcoded Korean exists outside the baked dictionary (a test enforces this).
192
+ - Display identity: English **Notify for Chrome**, Korean **알림 for 크롬**.
193
+
194
+ ## 6. Limitations
195
+
196
+ ### Browser support — Chrome / Chromium required
197
+
198
+ The plugin is built for **Chrome and Chromium-based browsers** (Edge, Brave, Opera, Vivaldi, Arc).
199
+ Those are the engines where the whole surface below exists at once. **On any other browser, correct
200
+ operation is not guaranteed and is not tested.**
201
+
202
+ | Capability | Chrome / Chromium | Firefox | Safari |
203
+ |---|---|---|---|
204
+ | `new Notification()` | yes | yes | 16+ only |
205
+ | `badge` / `setAppBadge` | 81+ | no (falls back silently) | no |
206
+ | `navigator.locks` (tab leader) | 69+ | yes | no (localStorage lease fallback) |
207
+ | `requireInteraction` | Windows/Linux | **ignored** | **ignored** |
208
+ | Installed-app notification branch | yes | n/a | n/a |
209
+
210
+ **The plugin does not detect the browser.** It feature-detects (`Notification`, `isSecureContext`,
211
+ `navigator.locks`, `setAppBadge`) and degrades, but it never claims a browser is supported just
212
+ because it is not Chrome. A non-Chromium browser may look functional while behaving differently —
213
+ for example Firefox accepts `requireInteraction` and ignores it, so an approval alert auto-dismisses
214
+ even though the setting is on.
215
+
216
+ Recommended minimum: **Chrome 81+** (when `setAppBadge` landed; everything else the plugin uses is
217
+ older). Older Chrome still works, minus the badge channel.
218
+
219
+ ### Security constraints that can stop a notification appearing
220
+
221
+ These are browser/OS security rules, not plugin bugs. Each one degrades to the **silent in-app
222
+ toast** rather than failing loudly, which is why the toast fallback exists.
223
+
224
+ | Constraint | What the user sees | Measured behaviour |
225
+ |---|---|---|
226
+ | **Permission not granted** (`default`) | nothing from the OS | `new Notification(...)` **does not throw** — it looks successful and shows nothing. The plugin checks `Notification.permission === 'granted'` *before* constructing, so it routes to the toast instead. |
227
+ | **Permission blocked** (`denied`) | nothing, and no prompt returns | `requestPermission()` resolves `'denied'` immediately; Chrome will not prompt again for the origin. Only the user can undo it in site settings. The plugin hides the Enable button, because a control that cannot work is not shown as a broken one. |
228
+ | **Insecure origin** | nothing | `http://<LAN-IP>:3080` is not a secure context; Chrome refuses OS notifications. Use `https://` or `localhost`. Reported as `insecure`. A self-signed certificate needs to be trusted once in the browser; do **not** browse with certificate errors bypassed — notifications and the peak timer need a genuinely secure context. |
229
+ | **No user gesture** | prompt never appears | `requestPermission()` needs a click. A call on page load is auto-denied; the plugin drives it from the Enable button only. |
230
+ | **OS Do Not Disturb** / Focus Assist | nothing, or a silent banner | The OS owns this; the page cannot override it and cannot detect it. |
231
+ | **Browser-level site mute** | nothing | Silent skip at the Chrome layer; indistinguishable from a delivered notification. |
232
+ | **Tab closed** | nothing | No service-worker backend, so no delivery once the DSH tab is gone. |
233
+
234
+ - Notifications work **only while the DSH tab is open**. There is no service-worker push backend.
235
+ - Background-tab throttling, browser notification policy and OS Focus Assist / Do Not Disturb can
236
+ change what is shown.
237
+ - Approval/question notifications do not replace the DSH approval UI; the event handler passes
238
+ `next()` through immediately.
239
+
240
+ ## 7. Privacy
241
+
242
+ - No `XMLHttpRequest`, `WebSocket`, `EventSource` or local port; no filesystem access.
243
+ - **One network request**: the peak timer fetches the Chinese public-holiday list from
244
+ `raw.githubusercontent.com/NateScarlet/holiday-cn`. Nothing is sent but the request itself — no
245
+ prompt, code, session title, token or identifier. The response is cached in `localStorage` for 10
246
+ days. Disable **Show peak countdown chip** and the request never happens.
247
+ - The only storage is the settings object, the tab-leader lease key and that holiday cache, all in
248
+ `localStorage`.
249
+ - Notification bodies are fixed copy plus the session title (80 chars max); prompts, code and tool
250
+ arguments are never included.
251
+ - Notification clicks only focus the DSH tab; no external link is opened.
252
+
253
+ ## 8. Development
254
+
255
+ ```bash
256
+ cd dsh-notify
257
+ npm test # pretest runs gen-locale + build-client, then node --test
258
+ npm run build # regenerate lib/locale-table.js and lib/client.js
259
+ node --check lib/client.js
260
+ node --check lib/index.js
261
+ ```
262
+
263
+ Tests use mocked browser APIs (Notification, localStorage, Web Locks) and assert event filtering,
264
+ cooldowns, approval/question pass-through, the Chrome-vs-toast channel decision, Windows/macOS/Linux
265
+ parameter branches, locale switching, settings persistence, native-primitive rendering, the
266
+ typography token contract, and the absence of every removed legacy path.
267
+
268
+ ## 9. Structure
269
+
270
+ - `lib/client.src.js` — editable browser-half source (baked by `scripts/build-client.mjs`).
271
+ - `lib/client.js` — shipped browser entry (generated; do not edit by hand).
272
+ - `lib/locale-table.js` — generated locale dictionary (from `locale/*.json`).
273
+ - `lib/index.js` — no-op Host half; registers the package only. No services, no I/O.
274
+ - `cordis.patch.yml` — bundle row and default settings.
275
+ - `market/index.json` — market/index entry (renamed titles, version, no audio keyword).
276
+
277
+ ## 10. License and assets
278
+
279
+ - Code: MIT. See [LICENSE](LICENSE).
280
+ - The package bundles **no fonts, logos or audio**. The manifest icon is the neutral clock SVG at
281
+ [`icon.svg`](icon.svg)/[`market/icons/dsh-notify.svg`](market/icons/dsh-notify.svg).
282
+ - The historical `docs/` and `prototype-windows/` trees are kept as history; neither is shipped in
283
+ the package (`package.json` `files`) nor referenced by the notification code path.
284
+
285
+ ---
286
+
287
+ ## 한국어 요약
288
+
289
+ **알림 for 크롬**은 DeepSeek Harness의 턴 완료·도구 승인·질문·오류를 **크롬 알림**(Web
290
+ Notification API)으로 알립니다. 크롬이 알림을 막거나 표시할 수 없으면 **소리 없이 DSH 인앱
291
+ 토스트**로 대신 알립니다. 자체 오디오 엔진·BYO 오디오·자체 CSS·네이티브 앱/포트/파일 경로는
292
+ 모두 제거되었습니다.
293
+
294
+ - 설정은 **설정 → 알림 for 크롬**에서 하며, DSH 네이티브 컴포넌트(Switch/Button/Tag/Input/
295
+ Toast)와 DSH 디자인·타이포그래피 토큰만 사용합니다.
296
+ - 영어가 기본이고, 호스트 로케일이 한국어일 때만 한국어로 표시됩니다.
297
+ - Windows에서는 플랫폼과 설치 환경(설치된 앱/브라우저 탭)에 따라 알림 파라미터가 달라집니다:
298
+ 설치된 앱은 모든 알림을 닫을 때까지 유지하고, 브라우저 탭은 승인·질문만 유지합니다.
299
+ macOS에서는 `requireInteraction`을 사용하지 않습니다.
@@ -0,0 +1,19 @@
1
+ # Notify for Chrome bundle layer.
2
+ # The package is the bundle; this row activates its no-op Host half. Every
3
+ # notification runs through the browser's Chrome / Web Notification API. No
4
+ # local IPC, HTTP, ports, filesystem access, native notifier or audio engine.
5
+ - insert:
6
+ - id: notify
7
+ name: '@nolit/dsh-notify'
8
+ config:
9
+ notifyOnComplete: true
10
+ notifyOnApproval: true
11
+ notifyOnQuestion: true
12
+ notifyOnError: true
13
+ completeCooldownMs: 5000
14
+ suppressWhenFocused: false
15
+ requireInteractionForApproval: true
16
+ notifyOnSubagents: false
17
+ showPeakChip: true
18
+ notifyOnPeakChange: true
19
+ peakOnAnyProvider: false
@@ -0,0 +1,335 @@
1
+ # DSH Notify — 설계 문서
2
+
3
+ <!-- skeleton:
4
+ version: 1.0
5
+ kind: design
6
+ status: historical
7
+ updated: 2026-10-20
8
+ summary: Original DSH Notify design: goal, scope, and the plugin shape chosen before implementation started.
9
+ -->
10
+
11
+
12
+ > 상태: active · 최종 갱신: 2026-10-20 · git: main @ milestone-1 · 상위: [BOOT-CARD.md](BOOT-CARD.md)
13
+
14
+ - 작성일: 2026-10-20
15
+ - 상태: 승인됨 (판정 코어 구현 완료, UI 미착수)
16
+ - 프로젝트 루트: `C:\Users\nolit\Workspace\projects\DSH Notify`
17
+ - 첫 기능: DeepSeek 과금 구간 인디케이터
18
+
19
+ ## 인덱스
20
+
21
+ | § | 섹션 | 이동 | 한 줄 요약 |
22
+ |---|---|---|---|
23
+ | 1 | 목적 | [§1](#1-목적) | 왜 만드는가, 성공과 안티골 |
24
+ | 2 | 구조 | [§2](#2-구조) | 계층, provider 계약, severity, 갱신 주기 |
25
+ | 3 | UI | [§3](#3-ui) | 인디케이터 창, 트레이, 알림 |
26
+ | 4 | 설정 | [§4](#4-설정) | config.json 기본값 |
27
+ | 5 | 오류 처리 원칙 | [§5](#5-오류-처리-원칙) | 안전 강등 표 |
28
+ | 6 | 파일 구성 | [§6](#6-파일-구성) | 최종 디렉터리 배치 |
29
+ | 7 | 확장 방법 | [§7](#7-확장-방법-미래) | provider 추가 4단계 |
30
+ | 8 | 확인된 기술 사실 | [§8](#8-확인된-기술-사실) | 재확인 불필요 항목 |
31
+ | 9 | 미결 사항 | [§9](#9-미결-사항) | 구현 중 결정 |
32
+ | 10 | 하지 않는 것 | [§10](#10-하지-않는-것) | 범위 밖 명시 |
33
+
34
+ ---
35
+
36
+ ## 1. 목적
37
+
38
+ DSH(DeepSeek Harness) 작업 중에 알아야 하는 외부 상태가 여러 개 있다. 지금은 그때그때 사람이 확인해야 하고, 확인을 잊으면 비용이나 시간이 낭비된다.
39
+
40
+ `DSH Notify`는 그 상태들을 한 곳에서 상시 보여주는 **로컬 알림 플랫폼**이다.
41
+
42
+ ### 왜 지금인가
43
+
44
+ DeepSeek API는 피크 시간에 2배, 그 외 시간에 1배의 요금을 매긴다(오프피크가 피크의 0.5배). 실측 기준 주간 시간의 약 79%가 오프피크인데, 그 사실을 모르면 비용이 큰 작업을 피크에 시작하게 된다. 이 하나만으로도 알림 인프라를 만들 값이 있다고 판단했다.
45
+
46
+ ### 성공의 정의
47
+
48
+ - 작업을 시작하기 전에 과금 구간을 확인하는 습관이 생긴다.
49
+ - 알림 소스를 추가할 때 UI를 다시 만들지 않는다.
50
+
51
+ ### 안티골: 이렇게 되면 실패다
52
+
53
+ - 인디케이터가 화면을 방해해서 꺼버리게 된다.
54
+ - 알림이 너무 잦아서 무시하게 된다.
55
+ - 판정이 틀려서 잘못된 구간을 알려준다.
56
+ - 프로세스가 죽어도 사용자가 알아채지 못한다.
57
+
58
+ ---
59
+
60
+ ## 2. 구조
61
+
62
+ ### 2.1 계층
63
+
64
+ ```
65
+ DSH Notify (단일 상주 프로세스)
66
+ │
67
+ ├── core.ps1 provider 로딩, 상태 집계, 설정 관리, 단일 인스턴스 보장
68
+ │
69
+ ├── providers/ 알림 소스 (각각 독립)
70
+ │ ├── deepseek-peak.ps1 DeepSeek 과금 구간 ◀ 첫 기능
71
+ │ ├── (비어 있음) 세션 길이·토큰 소모
72
+ │ ├── (비어 있음) 백그라운드 작업 완료
73
+ │ └── (비어 있음) 예약 작업 도래
74
+ │
75
+ ├── ui/
76
+ │ ├── indicator.ps1 반투명 인디케이터 창
77
+ │ └── tray.ps1 트레이 아이콘 + 풍선 알림
78
+ │
79
+ └── cache/ provider가 쓰는 캐시
80
+ ```
81
+
82
+ ### 2.2 Provider 계약
83
+
84
+ 모든 provider 스크립트는 **하나의 함수**를 내보낸다.
85
+
86
+ ```powershell
87
+ function Get-State {
88
+ param(
89
+ [datetime] $Now, # 로컬 시각 (provider가 필요 시 내부에서 UTC 변환)
90
+ [hashtable] $Config, # config.json 의 providers.<id> 섹션
91
+ [string] $CacheDir # 캐시 디렉터리 절대 경로
92
+ )
93
+
94
+ return [pscustomobject]@{
95
+ Id = 'deepseek-peak' # 고유 식별자
96
+ Label = 'DeepSeek 과금' # 화면 표시명
97
+ Severity = 'warn' # ok | warn | alert
98
+ Headline = 'PEAK · 100%' # 1줄 요약
99
+ Detail = '→ 다음 구간까지 2h 14m'
100
+ NextChangeAt = [datetime] # 다음 상태 변화 시각 (없으면 $null)
101
+ NotifyText = '대량 작업은 50% 구간까지 미루는 것을 검토하세요.'
102
+ }
103
+ }
104
+ ```
105
+
106
+ ### 2.3 Severity 체계
107
+
108
+ | severity | 창 색 | 트레이 아이콘 | 풍선 알림 |
109
+ |---|---|---|---|
110
+ | `ok` | 녹색 `#1E7B3C` | 녹색 | 없음 |
111
+ | `warn` | 주황 `#B26A00` | 주황 | 전환 시 1회 |
112
+ | `alert` | 적색 `#B3261E` | 적색 | 전환 시 1회 |
113
+
114
+ 여러 provider가 동시에 활성이면 **가장 높은 severity**가 창 색을 결정한다. 나머지는 창을 클릭했을 때 목록으로 보인다.
115
+
116
+ **DeepSeek 첫 기능의 매핑**
117
+ - 피크(100%) → `alert` (적색)
118
+ - 오프피크(50%) → `ok` (녹색)
119
+ - 판정 불가(데이터 없음) → `ok` + detail에 "추정" 표시
120
+
121
+ ### 2.4 갱신 주기
122
+
123
+ ```
124
+ 60초마다: 모든 provider의 Get-State 호출 → 상태 집계 → UI 반영
125
+ 캐시 만료 시(기본 240시간 = 10일): 기존 캐시로 즉시 응답 + 백그라운드 갱신
126
+ — 갱신은 스레드 작업 1개, 실패 시 1시간 쿨다운. 미공고 파일(papers: [])은 24시간 TTL
127
+ 하루 1회: 접속 지역(공인 IP) 확인 — 표시용. 판정은 UTC 고정이라 영향 없음
128
+ ```
129
+
130
+ 요일 규칙(평일 UTC 01–04, 06–10)은 고정이므로 매번 계산하고, 변하는 것은 공휴일 데이터뿐이다.
131
+ 중국 공휴일 달력은 연간 공고가 11월에 확정되고 연중 임시휴일이 드물게 추가되므로 10일 주기면
132
+ 충분하다. 갱신은 타이머가 아니라 "만료 후 첫 호출"에서 시작되며, 폴링을 막지 않는다(lazy + async).
133
+
134
+ 기기 시간대는 해외에서도 KST 로 남아 있을 수 있다(노트북은 모바일처럼 자동 변경을 기대하기 어렵다).
135
+ 그래서 하루 1회 IP 로 접속 지역 시간대를 확인해 두고, 기기 시간대와 다르면 UI 가 두 시간대를 병기한다.
136
+ IP 는 VPN 으로 바뀔 수 있으므로 표시 힌트로만 쓰고 판정에는 쓰지 않는다(§4.4 in spec).
137
+
138
+ provider가 실패하면 그 provider만 건너뛰고 나머지는 계속 돈다. 전체가 죽지 않는다.
139
+
140
+ **시작 경량화**: 창은 즉시 `00:00:00` 자리 상태로 뜨고, 5초 뒤 백그라운드 스레드에서 공휴일·지역
141
+ 동기화를 수행한다. 동기화가 끝나면 값이 채워지고, 이후 60초 폴링으로 이어진다.
142
+ 시작·동기화·폴링 오류는 `cache\core.log` 에 남긴다(진단용, git 제외).
143
+
144
+ ---
145
+
146
+ ## 3. UI
147
+
148
+ ### 3.1 인디케이터 창
149
+
150
+ | 항목 | 값 |
151
+ |---|---|
152
+ | 크기 | 210 × 92 px |
153
+ | 배경 | 짙은 무채색 `#121212`, 알파 0.6, 모서리 10px, 테두리 없음 |
154
+ | 최상위 | 예 |
155
+ | 작업표시줄 | 미표시 |
156
+ | 이동 | 창 전체 드래그 |
157
+ | 위치 저장 | `config.json` |
158
+
159
+ **표시 내용** — 강조는 하나만: digital-7 타이머. 모두 영어, 가운데 정렬.
160
+ ```
161
+ ┌──────────────────────────────┐
162
+ │ DeepSeek Peak (100%) ← Pretendard 10, CI 블루 #4D6BFE, 중앙
163
+ │ 02:14:30 ← digital-7 46px, 중앙
164
+ │ 본문(HH:MM) 피크 #E5534B / 오프 #4ADE80
165
+ │ 콜론·초(:SS)는 배경색 #2C2C2C (존재감 최소)
166
+ │ Local 18:00–21:00 (UTC-7) ← 기기/현지 불일치일 때만, 9px, 중앙
167
+ └──────────────────────────────┘
168
+ ```
169
+
170
+ - 아이콘(점) 없음. 상태는 **제목 문구** + **타이머 색**으로 전달
171
+ - 제목: `DeepSeek Peak (100%)` / `DeepSeek Off-Peak (50% discount)`. 50%는 사용량이 아니라 **피크 대비 가격 할인율**임을 명시한다
172
+ - 타이머는 `HH:MM:SS`. 앞의 `HH:MM` 만 색을 주고 **콜론과 초**는 배경과 같은 회색으로 죽인다
173
+ - 시작 시 동기화(공휴일/지역)를 기다리지 않는다: 카드는 즉시 `00:00:00` 으로 뜨고, 5초 뒤 백그라운드 스레드에서 동기화해 값이 채워진다
174
+ - 타이머 글꼴: `ui\assets\digital-7.ttf` 임베드, `file://…digital-7.ttf#Digital-7` 로드(없으면 Consolas 폴백)
175
+ - digital-7 은 개인용 무료 폰트. 라이선스 원문: `ui\assets\digital-7-license.txt`
176
+ - 제목·현지 줄은 **시스템 설치 Pretendard**(`C:\WINDOWS\FONTS\PRETENDARD-REGULAR.OTF`). 임베드 폰트는 타이머 하나뿐
177
+ - `tests\font.ps1` F4 가 WPF 가 실제로 Pretendard 파일을 해석하는지 확인한다
178
+ - 레이아웃 Grid: 제목 Top, 타이머 Center, 현지 줄 Bottom — 타이머가 카드 정중앙
179
+ - 타이머는 1초마다 재계산하지만 표시는 분 단위, 서버 호출 없음
180
+
181
+ 실제 렌더(2배 확대, 어두운 배경 합성):
182
+
183
+ *[이미지: PEAK 카드 — `previews/indicator-alert.png`, 이 저장소에 포함되지 않음]*
184
+ *[이미지: OFF-PEAK 카드 — `previews/indicator-ok.png`, 이 저장소에 포함되지 않음]*
185
+
186
+ - 표시 모델: `ui\presenter.ps1` (순수) / 카드: `ui\indicator.ps1`
187
+ - 창 없이 렌더 검증: `pwsh -Sta -File ui\render-indicator.ps1 -Severity alert -Out out.png`
188
+
189
+ **표시 모드** (`config.displayMode`)
190
+
191
+ | 값 | 동작 |
192
+ |---|---|
193
+ | `always` | 항상 표시, 색만 바뀜 (기본값) |
194
+ | `peakOnly` | `alert` 또는 `warn` 일 때만 창 표시 |
195
+
196
+ ### 3.2 트레이
197
+
198
+ - `NotifyIcon` 상주, 아이콘 색도 severity에 맞춰 교체
199
+ - 아이콘 자산: `ui\assets\deepseek-logo-source.png`(워드마크 포함 원본)
200
+ → `ui\prepare-logo.ps1`(연결요소로 심볼만 크롭 → `ui\assets\deepseek-logo.png` 222px 마스터)
201
+ → `ui\build-icons.ps1` → `ui\icons\deepseek-{green,red,amber,gray}.ico`
202
+ - 현재 아이콘은 **green(ok) / red(alert) 2종**. provider 가 내는 상태가 둘뿐이라 warn·unknown 자산은 만들지 않는다
203
+ - `warn`(amber `#B26A00`)·`unknown`(gray `#6B7280`) 은 생산자가 생길 때 `ui\icons.ps1` 의 Colors 에 한 줄 추가 후 재생성
204
+ - 16/20/24/32/48/256 멀티 사이즈 PNG-in-ICO. 테스트: `tests\icons.ps1`, `tests\logo-mask.ps1`
205
+ - 워드마크가 섞인 원본에서 심볼만 잘라야 16px 에서 깨끗하다. 크롭은 알파 연결요소 최대값 기준
206
+ - 원본 로고는 DeepSeek 상표다. 로컬 트레이 표시에만 쓰고 배포물에 재배포하지 않는다
207
+ - 우클릭 메뉴: `지금 갱신` / **`알림`(체크)** / **`OLED 보호`(체크)** / `항상 표시`(체크) / `피크일 때만`(체크) / `위치 초기화` / **`카드 보이기`(체크)** / `종료`
208
+ - 체크 표시로 현재 상태를 바로 알 수 있다. `알림` 을 끄면 피크 전환 토스트와 DSH 완료/승인 팝업이 모두 멈춘다(config `notifications`)
209
+ - `OLED 보호` 를 켜면 `oledIntervalSeconds`(기본 60초)마다 카드를 **5px 씩 네 방향으로 순환**시킨다(번인 완화). 저장되는 기준 위치는 그대로 두고 표시 오프셋만 움직인다
210
+ - `카드 보이기` 체크는 실제 창 표시 상태(`IsVisible`)를 반영한다. 끄면 숨김, 켜면 표시
211
+ - 툴팁에 전체 provider 상태 요약
212
+
213
+ ### 3.3 알림
214
+
215
+ - severity가 **바뀔 때만** 1회 풍선 알림
216
+ - 같은 severity가 유지되는 동안 재알림 없음
217
+ - 상태 파일(`cache/notify-state.json`)에 마지막 severity 기록
218
+
219
+ ---
220
+
221
+ ## 4. 설정
222
+
223
+ `config.json` (프로젝트 루트)
224
+
225
+ ```json
226
+ {
227
+ "displayMode": "always",
228
+ "opacity": 0.6,
229
+ "position": { "x": 40, "y": 40 },
230
+ "notifyOnChange": true,
231
+ "notifications": true,
232
+ "oledProtection": false,
233
+ "oledIntervalSeconds": 60,
234
+ "pollSeconds": 60,
235
+ "providers": {
236
+ "deepseek-peak": {
237
+ "enabled": true,
238
+ "treatMakeupAsPeak": true,
239
+ "refreshHours": 240
240
+ }
241
+ }
242
+ }
243
+ ```
244
+
245
+ 설정 파일이 없으면 기본값으로 생성한다. 잘못된 JSON이면 기본값으로 되돌리고 트레이 툴팁에 경고를 남긴다.
246
+
247
+ ---
248
+
249
+ ## 5. 오류 처리 원칙
250
+
251
+ 핵심 원칙: **정보가 부족하면 안전한 쪽으로 강등하고, 강등 사실을 숨기지 않는다.**
252
+
253
+ | 상황 | 동작 |
254
+ |---|---|
255
+ | 네트워크 없음 | 캐시 사용 → 캐시도 없으면 주말 규칙만 |
256
+ | 공휴일 JSON 파싱 실패 | 해당 파일 무시, 캐시 사용, 주말 규칙 |
257
+ | 해당 연도 데이터 미공고 | `papers: []` 감지 → 주말 규칙, detail에 "공고 전" 표시 |
258
+ | provider 스크립트 오류 | 해당 provider만 제외, 나머지 계속 |
259
+ | 트레이 아이콘 생성 실패 | 창만으로 동작 계속 |
260
+ | 이미 실행 중 | 뮤텍스로 중복 차단, 기존 인스턴스에 신호 |
261
+ | config 손상 | 기본값 사용 + 경고 표시 |
262
+
263
+ ---
264
+
265
+ ## 6. 파일 구성
266
+
267
+ ```
268
+ DSH Notify\
269
+ docs\
270
+ 2026-10-20-dsh-notify-design.md 이 문서
271
+ deepseek-peak-spec.md 첫 기능 상세
272
+ BOOT-CARD.md 새 창 진입 카드
273
+ providers\
274
+ deepseek-peak.ps1
275
+ _template.ps1 새 provider 작성용 빈 틀
276
+ ui\
277
+ indicator.ps1
278
+ tray.ps1
279
+ cache\ 런타임 생성 (git 제외)
280
+ tests\
281
+ peak-decisions.ps1 판정 단위 테스트
282
+ core.ps1
283
+ config.json 런타임 생성
284
+ start.vbs 콘솔 없이 실행
285
+ README.md
286
+ ```
287
+
288
+ ---
289
+
290
+ ## 7. 확장 방법 (미래)
291
+
292
+ 새 provider를 추가하는 절차:
293
+
294
+ 1. `providers/_template.ps1`를 복사해 이름 변경
295
+ 2. `Get-State` 구현
296
+ 3. `config.json`의 `providers.<id>`에 `enabled: true` 추가
297
+ 4. UI는 손대지 않음
298
+
299
+ 이 절차가 4단계를 넘으면 구조가 잘못된 것이므로 계획을 다시 세운다.
300
+
301
+ ---
302
+
303
+ ## 8. 확인된 기술 사실
304
+
305
+ 조사 시점(2026-10-20) 기준 실제 확인한 것:
306
+
307
+ - PowerShell 7.6.7
308
+ - `System.Windows.Forms` 로드 성공
309
+ - `PresentationFramework` (WPF) 로드 성공
310
+ - `schtasks.exe` 존재
311
+ - 프로젝트 디렉터리는 원래 비어 있었음
312
+
313
+ 이 사실들은 구현 시 전제로 삼는다. 재확인이 필요한 항목은 아니다.
314
+
315
+ ---
316
+
317
+ ## 9. 미결 사항
318
+
319
+ 구현 단계에서 결정할 것:
320
+
321
+ | 항목 | 선택지 | 결정 시점 |
322
+ |---|---|---|
323
+ | 자동 시작 등록 | `HKCU\...\Run` 등록 여부 | 구현 후 사용자 확인 |
324
+ | 대체근무일 해석 | 피크로 볼지 오프피크로 볼지 | 설정 플래그로 양쪽 지원 |
325
+ | 향후 provider 우선순위 | 세션/작업/예약 중 무엇 먼저 | 첫 기능 검증 후 |
326
+ | DSH 플러그인 초안 처리 | `C:\Users\nolit\deepseek-peak-dsh` 삭제 여부 | 구현 후 |
327
+
328
+ ---
329
+
330
+ ## 10. 하지 않는 것
331
+
332
+ - **DSH 프로필 변경** — 이 플랫폼은 DSH 외부에서 독립 동작한다
333
+ - **하네스 실행 감지** — GUI 앱의 실행 시점을 외부에서 안정적으로 잡기 어렵다. 대신 구간 전환 알림으로 같은 목적을 달성한다
334
+ - **요금 조작** — 불가능하며 시도하지 않는다
335
+ - **클라우드 동기화** — 로컬 전용. 설정은 파일로 관리한다