dsh-macos-notify 0.4.0 → 0.4.1

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/README.md CHANGED
@@ -13,7 +13,7 @@ Native macOS notifications for [DeepSeek Harness](https://github.com/deepseek-ai
13
13
  - A six-event test matrix validates completed, error, approval, aborted, coalesced, and digest notifications.
14
14
  - Daily quiet hours and temporary pauses with custom durations and a remaining-time display.
15
15
  - Common toggles and the sound picker save instantly from the read-only view; number inputs are clamped to safe ranges.
16
- - Duplicate error suppression with a configurable cooldown window.
16
+ - Duplicate error suppression with a configurable cooldown window and volatile-fragment normalization.
17
17
  - Project path rules for muting, error-only alerts, or important-project bypasses.
18
18
  - Minimum turn-duration filtering avoids notifications for near-instant replies.
19
19
  - Optional Web-tab focus suppression and macOS HID idle-time gating.
package/index.js CHANGED
@@ -7,15 +7,17 @@ import { promisify } from 'node:util'
7
7
  import Schema from '@deepseek-ai/schemastery'
8
8
  import {
9
9
  DuplicateTracker,
10
+ SOUND_KINDS,
10
11
  TtlCache,
11
12
  buildNotificationScript,
12
13
  duplicateKey,
13
14
  isCompletionKind,
14
15
  isCriticalKind,
15
16
  matchingProjectRule,
16
- minuteOfDay,
17
17
  parseProjectRules,
18
18
  quietHoursActive,
19
+ truncateNotification,
20
+ validateSettingsPatch,
19
21
  } from './src/policy.js'
20
22
  import { loadStateSync, saveState } from './src/state.js'
21
23
 
@@ -70,8 +72,6 @@ export const Config = Schema.object({
70
72
  notifyOnLoad: Schema.boolean().default(false),
71
73
  })
72
74
 
73
- /** 设置页可编辑的提示音字段 */
74
- const SOUND_KINDS = ['completed', 'error', 'aborted', 'approval']
75
75
  const SOUND_EXTENSIONS = new Set(['.aif', '.aiff', '.caf', '.m4a', '.wav'])
76
76
  const IMPORT_EXTENSIONS = new Set([
77
77
  '.aac', '.aif', '.aiff', '.caf', '.flac', '.m4a', '.mp3', '.oga', '.ogg', '.opus', '.wav',
@@ -354,7 +354,8 @@ function sanitizeOsc9(s) {
354
354
  }
355
355
 
356
356
  function emitOsc9(title, body) {
357
- const message = [title, body].map(sanitizeOsc9).filter(Boolean).join(': ').slice(0, 256)
357
+ const truncated = truncateNotification(title, body)
358
+ const message = [truncated.title, truncated.body].map(sanitizeOsc9).filter(Boolean).join(': ').slice(0, 256)
358
359
  if (!message) return
359
360
  let seq = `\x1b]9;${message}\x07`
360
361
  // tmux 会吞掉 OSC,需要 DCS passthrough 包裹并把载荷里的 ESC 双写
@@ -588,7 +589,7 @@ function applyImpl(ctx, config) {
588
589
  if (op === 'get') return { ok: true, value: scope.get() }
589
590
  if (op === 'set' && typeof payload.field === 'string') {
590
591
  try {
591
- await scope.update({ [payload.field]: payload.value })
592
+ await scope.update(validateSettingsPatch({ [payload.field]: payload.value }, current))
592
593
  return { ok: true, value: scope.get() }
593
594
  } catch (err) {
594
595
  return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
@@ -596,24 +597,7 @@ function applyImpl(ctx, config) {
596
597
  }
597
598
  if (op === 'patch' && payload.value && typeof payload.value === 'object' && !Array.isArray(payload.value)) {
598
599
  try {
599
- const editable = new Set([
600
- 'onCompleted', 'onError', 'onAborted', 'onApproval', 'minDurationSec',
601
- 'onlyWhenIdleSec', 'onlyWhenUnfocused', 'digestMinutes', 'includeSubagents',
602
- 'channel', 'sounds', 'coalesceMs', 'quietHoursEnabled', 'quietStart', 'quietEnd',
603
- 'quietAllowCritical', 'pauseUntil', 'duplicateWindowSec', 'projectRulesJson',
604
- ])
605
- if (Object.keys(payload.value).some((key) => !editable.has(key))) throw new Error('包含不可编辑的设置字段')
606
- for (const field of ['quietStart', 'quietEnd']) {
607
- if (field in payload.value && minuteOfDay(payload.value[field]) === null) throw new Error('勿扰时间格式无效')
608
- }
609
- if (typeof payload.value.projectRulesJson === 'string') {
610
- const parsed = JSON.parse(payload.value.projectRulesJson)
611
- if (!Array.isArray(parsed) || parsed.length > 50) throw new Error('项目规则格式无效')
612
- if (parsed.some((rule) => typeof rule?.path !== 'string' || !['mute', 'errors', 'important'].includes(rule?.mode))) {
613
- throw new Error('项目规则格式无效')
614
- }
615
- }
616
- await scope.update(payload.value)
600
+ await scope.update(validateSettingsPatch(payload.value, current))
617
601
  return { ok: true, value: scope.get() }
618
602
  } catch (err) {
619
603
  return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-macos-notify",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Native macOS notifications and configurable sounds for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/policy.js CHANGED
@@ -58,8 +58,18 @@ export function matchingProjectRule(rules, cwd) {
58
58
  .sort((a, b) => b.path.length - a.path.length)[0] ?? null
59
59
  }
60
60
 
61
+ /** 重复指纹归一化:只收敛已知易变片段(UUID、长 token、带单位的数量/时长),
62
+ * 裸数字(状态码、端口、行号)原样保留,避免把不同错误合并成同一个 */
63
+ export function normalizeDuplicateText(text) {
64
+ let value = String(text ?? '').replace(/\s+/g, ' ').trim()
65
+ value = value.replace(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g, '<uuid>')
66
+ value = value.replace(/(?<![0-9a-zA-Z_])[0-9a-fA-F]{16,}(?![0-9a-zA-Z_])/gi, '<token>')
67
+ value = value.replace(/(\d+(?:\.\d+)?)\s*(毫秒|秒|分钟|小时|次|个|MB|KB|GB|ms|s|min|seconds?|secs?)(?![0-9a-zA-Z_])/gi, '<n> $2')
68
+ return value.length > 500 ? value.slice(0, 500) : value
69
+ }
70
+
61
71
  export function duplicateKey(item) {
62
- return `${item.sessionId ?? ''}\u0000${item.kind}\u0000${item.body}`
72
+ return `${item.sessionId ?? ''}\u0000${item.kind}\u0000${normalizeDuplicateText(item.body)}`
63
73
  }
64
74
 
65
75
  /**
@@ -108,12 +118,112 @@ export class DuplicateTracker {
108
118
  }
109
119
  }
110
120
 
121
+ /** 声音事件字段;服务端与客户端共享同一集合 */
122
+ export const SOUND_KINDS = ['completed', 'error', 'aborted', 'approval']
123
+
124
+ export const MAX_NOTIFICATION_TITLE = 120
125
+ export const MAX_NOTIFICATION_BODY = 500
126
+
127
+ /** 通知文案截断(内容策略):标题/正文各自封顶并加省略号;通道级的总长限制另算 */
128
+ export function truncateNotification(title, body) {
129
+ const trim = (value, max) => {
130
+ const text = String(value ?? '')
131
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text
132
+ }
133
+ return { title: trim(title, MAX_NOTIFICATION_TITLE), body: trim(body, MAX_NOTIFICATION_BODY) }
134
+ }
135
+
111
136
  /** AppleScript 字符串转义 + 通知脚本组装;换行会截断 display notification 的字面量,压成空格 */
112
137
  export function buildNotificationScript(title, body, sound) {
138
+ const truncated = truncateNotification(title, body)
113
139
  const clean = (value) => String(value).replace(/\r?\n/g, ' ')
114
140
  const quote = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
115
141
  const soundPart = sound ? ` sound name "${quote(sound)}"` : ''
116
- return `display notification "${quote(clean(body))}" with title "${quote(clean(title))}"${soundPart}`
142
+ return `display notification "${quote(clean(truncated.body))}" with title "${quote(clean(truncated.title))}"${soundPart}`
143
+ }
144
+
145
+ /** settings set/patch 共用的可编辑字段与校验(服务端唯一真相;客户端只做输入提示) */
146
+ export const EDITABLE_SETTINGS = new Set([
147
+ 'onCompleted', 'onError', 'onAborted', 'onApproval', 'minDurationSec',
148
+ 'onlyWhenIdleSec', 'onlyWhenUnfocused', 'digestMinutes', 'includeSubagents',
149
+ 'channel', 'sounds', 'coalesceMs', 'quietHoursEnabled', 'quietStart', 'quietEnd',
150
+ 'quietAllowCritical', 'pauseUntil', 'duplicateWindowSec', 'projectRulesJson',
151
+ ])
152
+ /** 数值字段的合法区间(与客户端 NUMBER_BOUNDS 一致,服务端强制执行) */
153
+ export const NUMBER_BOUNDS = {
154
+ minDurationSec: [0, 3600],
155
+ onlyWhenIdleSec: [0, 3600],
156
+ digestMinutes: [0, 1440],
157
+ coalesceMs: [0, 60000],
158
+ duplicateWindowSec: [0, 86400],
159
+ }
160
+ const BOOLEAN_SETTINGS = new Set([
161
+ 'onCompleted', 'onError', 'onAborted', 'onApproval',
162
+ 'onlyWhenUnfocused', 'includeSubagents', 'quietHoursEnabled', 'quietAllowCritical',
163
+ ])
164
+ const CHANNELS = new Set(['auto', 'osascript', 'osc9'])
165
+
166
+ export function assertProjectRulesJson(raw) {
167
+ let parsed
168
+ try {
169
+ parsed = JSON.parse(raw)
170
+ } catch {
171
+ throw new Error('项目规则格式无效')
172
+ }
173
+ if (!Array.isArray(parsed) || parsed.length > 50) throw new Error('项目规则格式无效')
174
+ if (parsed.some((rule) => typeof rule?.path !== 'string' || !['mute', 'errors', 'important'].includes(rule?.mode))) {
175
+ throw new Error('项目规则格式无效')
176
+ }
177
+ return raw
178
+ }
179
+
180
+ function validateSettingsField(field, value, current) {
181
+ if (BOOLEAN_SETTINGS.has(field)) {
182
+ if (typeof value !== 'boolean') throw new Error(`${field} 必须为布尔值`)
183
+ return value
184
+ }
185
+ if (NUMBER_BOUNDS[field]) {
186
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`${field} 必须为有限数值`)
187
+ const [min, max] = NUMBER_BOUNDS[field]
188
+ if (value < min || value > max) throw new Error(`${field} 超出范围 [${min}, ${max}]`)
189
+ return value
190
+ }
191
+ if (field === 'pauseUntil') {
192
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new Error('pauseUntil 必须为非负数值')
193
+ return value
194
+ }
195
+ if (field === 'channel') {
196
+ if (!CHANNELS.has(value)) throw new Error('channel 取值无效')
197
+ return value
198
+ }
199
+ if (field === 'quietStart' || field === 'quietEnd') {
200
+ if (minuteOfDay(value) === null) throw new Error('勿扰时间格式无效')
201
+ return value
202
+ }
203
+ if (field === 'sounds') {
204
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('sounds 格式无效')
205
+ for (const [kind, name] of Object.entries(value)) {
206
+ if (!SOUND_KINDS.includes(kind)) throw new Error(`未知的声音字段:${kind}`)
207
+ if (typeof name !== 'string') throw new Error(`声音 ${kind} 必须为字符串`)
208
+ }
209
+ return { ...(current?.sounds ?? {}), ...value }
210
+ }
211
+ if (field === 'projectRulesJson') {
212
+ if (typeof value !== 'string') throw new Error('项目规则格式无效')
213
+ return assertProjectRulesJson(value)
214
+ }
215
+ throw new Error(`不可编辑的设置字段:${field}`)
216
+ }
217
+
218
+ /** 校验并归一化设置 patch;set 端点用单字段对象调用同一入口 */
219
+ export function validateSettingsPatch(patch, current = {}) {
220
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('设置格式无效')
221
+ const normalized = {}
222
+ for (const [field, value] of Object.entries(patch)) {
223
+ if (!EDITABLE_SETTINGS.has(field)) throw new Error('包含不可编辑的设置字段')
224
+ normalized[field] = validateSettingsField(field, value, current)
225
+ }
226
+ return normalized
117
227
  }
118
228
 
119
229
  /** 极简 TTL 缓存:命中返回 { hit: true, value },过期或未填充返回 { hit: false } */