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 +1 -1
- package/index.js +7 -23
- package/package.json +1 -1
- package/src/policy.js +112 -2
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
|
|
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
|
-
|
|
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
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 } */
|