@game25011/dsh-notify-toast 0.0.0-stage → 0.4.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.
@@ -0,0 +1,438 @@
1
+ /**
2
+ * dsh-notify-toast — goal-mode completion aggregation.
3
+ *
4
+ * Goal mode can run many automatic Goal Rounds. Per-turn completion toasts
5
+ * would be noisy and would make the "N steps" title meaningless. This pure
6
+ * tracker observes the goal lifecycle independently of delivery settings,
7
+ * suppresses only completions belonging to a tracked goal run, and emits one
8
+ * summary when that run segment stops.
9
+ *
10
+ * It is intentionally side-effect free apart from injected callbacks/timers so
11
+ * the state machine can be unit-tested without a DSH process.
12
+ */
13
+
14
+ function isRecord(value) {
15
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
16
+ }
17
+
18
+ function sessionIdOf(value) {
19
+ return typeof value === 'string' && value.length > 0 ? value : undefined
20
+ }
21
+
22
+ function nonNegativeInteger(value) {
23
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined
24
+ }
25
+
26
+ function normalizedKey(value) {
27
+ if (typeof value === 'string' && value.length > 0) return value
28
+ if (Number.isSafeInteger(value)) return String(value)
29
+ return undefined
30
+ }
31
+
32
+ /**
33
+ * @param {object} [options]
34
+ * @param {() => boolean} [options.enabled] live delivery setting.
35
+ * @param {(payload: object) => unknown} [options.onSummary] publishes one summary completion.
36
+ * @param {() => number} [options.getIntervalMs] settle/merge delay.
37
+ * @param {() => number} [options.now]
38
+ * @param {(callback: () => void, ms: number) => unknown} [options.setTimer]
39
+ * @param {(timer: unknown) => void} [options.clearTimer]
40
+ */
41
+ export function createGoalSummaryTracker(options = {}) {
42
+ const {
43
+ enabled = () => true,
44
+ onSummary = () => {},
45
+ getIntervalMs = () => 2500,
46
+ now = () => Date.now(),
47
+ setTimer = (callback, ms) => setTimeout(callback, ms),
48
+ clearTimer = (timer) => clearTimeout(timer),
49
+ } = options
50
+
51
+ /** @type {Map<string, any>} */
52
+ const runs = new Map()
53
+ const activeTurns = new Set()
54
+ const timers = new Set()
55
+
56
+ function deliveryEnabled() {
57
+ try {
58
+ return enabled() !== false
59
+ } catch {
60
+ return false
61
+ }
62
+ }
63
+
64
+ function cancelTimer(state) {
65
+ if (!state || state.timer === null || state.timer === undefined) return
66
+ try {
67
+ clearTimer(state.timer)
68
+ } catch {
69
+ // ignore
70
+ }
71
+ timers.delete(state.timer)
72
+ state.timer = null
73
+ }
74
+
75
+ function close(state, accountedTurn) {
76
+ if (!state) return
77
+ cancelTimer(state)
78
+ state.closed = true
79
+ state.terminal = true
80
+ state.active = false
81
+ state.armed = false
82
+ state.pending = null
83
+ state.accountedTurn = normalizedKey(accountedTurn) ?? null
84
+ }
85
+
86
+ function runCount(state) {
87
+ const baseline = nonNegativeInteger(state.baselineRounds) ?? 0
88
+ const last = nonNegativeInteger(state.lastRounds)
89
+ // Never invent a round from a turn count. A missing/zero authoritative
90
+ // counter means there is no reportable Goal Round progress.
91
+ return last === undefined ? 0 : Math.max(0, last - baseline)
92
+ }
93
+
94
+ function emitSummary(sessionId, state) {
95
+ const count = runCount(state)
96
+ if (count < 1) return false
97
+ const pending = state.pending || {}
98
+ const payload = {
99
+ kind: 'complete',
100
+ sessionId,
101
+ text: typeof pending.text === 'string' && pending.text.length > 0 ? pending.text : null,
102
+ count,
103
+ goalOutcome:
104
+ state.phase === 'complete' ? 'complete' : state.phase === 'paused' ? 'paused' : 'blocked',
105
+ allDone: false,
106
+ }
107
+ if (typeof pending.sessionTitle === 'string' && pending.sessionTitle.length > 0) {
108
+ payload.sessionTitle = pending.sessionTitle
109
+ }
110
+ try {
111
+ onSummary(payload)
112
+ } catch {
113
+ // Notification delivery must never break the host event loop.
114
+ }
115
+ return true
116
+ }
117
+
118
+ function finalize(sessionId, state) {
119
+ if (!state || state.closed) return
120
+ cancelTimer(state)
121
+ if (deliveryEnabled()) emitSummary(sessionId, state)
122
+ close(state, state.pending && state.pending.turnKey)
123
+ }
124
+
125
+ function flush(sessionId) {
126
+ const state = runs.get(sessionId)
127
+ if (!state || state.closed) return
128
+ state.timer = null
129
+ if (!deliveryEnabled()) {
130
+ close(state, state.pending && state.pending.turnKey)
131
+ return
132
+ }
133
+ emitSummary(sessionId, state)
134
+ close(state, state.pending && state.pending.turnKey)
135
+ }
136
+
137
+ function schedule(sessionId, state) {
138
+ if (!state || state.closed) return
139
+ if (state.timer !== null && state.timer !== undefined) return
140
+ if (!deliveryEnabled()) {
141
+ close(state, state.pending && state.pending.turnKey)
142
+ return
143
+ }
144
+ let delay = 0
145
+ try {
146
+ const value = Number(getIntervalMs())
147
+ if (Number.isFinite(value)) delay = Math.max(0, Math.floor(value))
148
+ } catch {
149
+ delay = 0
150
+ }
151
+ const timer = setTimer(() => {
152
+ timers.delete(timer)
153
+ flush(sessionId)
154
+ }, delay)
155
+ state.timer = timer
156
+ timers.add(timer)
157
+ }
158
+
159
+ function start(sessionId, goal, rounds) {
160
+ const existing = runs.get(sessionId)
161
+ const previousTurnActive = Boolean((existing && existing.turnActive) || activeTurns.has(sessionId))
162
+ if (existing && !existing.closed && (existing.terminal || existing.timer !== null || existing.pending)) {
163
+ // A pause followed by a quick resume must finalize the old segment before
164
+ // the new one starts; otherwise the old round delta is lost or mixed in.
165
+ finalize(sessionId, existing)
166
+ } else if (existing) {
167
+ cancelTimer(existing)
168
+ }
169
+ const baseline = nonNegativeInteger(rounds)
170
+ const state = {
171
+ goalId: sessionIdOf(goal && goal.id),
172
+ revision: nonNegativeInteger(goal && goal.revision),
173
+ phase: typeof goal?.phase === 'string' ? goal.phase : 'active',
174
+ active: true,
175
+ armed: true,
176
+ baselineRounds: baseline === undefined ? 0 : baseline,
177
+ lastRounds: baseline,
178
+ pending: null,
179
+ timer: null,
180
+ closed: false,
181
+ terminal: false,
182
+ turnActive: previousTurnActive,
183
+ awaitingTurnEnd: false,
184
+ accountedTurn: null,
185
+ deliveryOff: !deliveryEnabled(),
186
+ }
187
+ runs.set(sessionId, state)
188
+ }
189
+
190
+ /** Process one persisted goal/change event. */
191
+ function onGoalChange(sessionId, change) {
192
+ if (!sessionId || !isRecord(change) || change.kind !== 'goal/change') return false
193
+ const operation = typeof change.operation === 'string' ? change.operation : ''
194
+ const state = runs.get(sessionId)
195
+ const goal = isRecord(change.goal) ? change.goal : null
196
+
197
+ if (operation === 'clear') {
198
+ const cleared = isRecord(change.cleared) ? change.cleared : null
199
+ if (state && cleared && typeof cleared.id === 'string' && state.goalId && cleared.id !== state.goalId) {
200
+ return false // stale tombstone for a previous goal
201
+ }
202
+ if (state) cancelTimer(state)
203
+ runs.delete(sessionId)
204
+ return true
205
+ }
206
+ if (!goal || typeof goal.id !== 'string') return false
207
+ const revision = nonNegativeInteger(goal.revision)
208
+ const rounds = nonNegativeInteger(change.roundsStarted)
209
+ const phase = typeof goal.phase === 'string' ? goal.phase : 'active'
210
+
211
+ if (operation === 'create' || operation === 'resume') {
212
+ if (
213
+ state &&
214
+ !state.closed &&
215
+ state.goalId === goal.id &&
216
+ revision !== undefined &&
217
+ state.revision !== undefined &&
218
+ revision < state.revision
219
+ ) {
220
+ return false
221
+ }
222
+ start(sessionId, goal, rounds)
223
+ return true
224
+ }
225
+
226
+ if (!state) return false
227
+ if (state.goalId && goal.id !== state.goalId) return false
228
+ if (revision !== undefined && state.revision !== undefined && revision < state.revision) {
229
+ return false // stale transition
230
+ }
231
+ if (state.closed) {
232
+ // Duplicate/late terminal event for an already finalized segment.
233
+ return true
234
+ }
235
+ state.revision = revision ?? state.revision
236
+ state.phase = phase
237
+ if (rounds !== undefined) state.lastRounds = Math.max(nonNegativeInteger(state.lastRounds) ?? 0, rounds)
238
+ if (operation === 'edit') return true
239
+ if (operation === 'pause' || operation === 'complete' || operation === 'block') {
240
+ if (state.terminal) return true // duplicate terminal before flush
241
+ state.active = false
242
+ state.armed = false
243
+ state.terminal = true
244
+ // A goal may terminate inside its final turn (DSH commits completion and
245
+ // then supplies the wrap-up turn). Do not settle before that turn ends,
246
+ // or the final text is missed and the final turn is notified separately.
247
+ if (state.turnActive) {
248
+ state.awaitingTurnEnd = true
249
+ } else {
250
+ schedule(sessionId, state)
251
+ }
252
+ return true
253
+ }
254
+ return true
255
+ }
256
+
257
+ /**
258
+ * Observe admission of the next Goal Round. The durable goal/change counter
259
+ * is also advanced by these user/message events; tracking them keeps the
260
+ * count correct when a run ends by activation disarm rather than a terminal
261
+ * goal/change snapshot.
262
+ */
263
+ function onGoalRound(sessionId, source) {
264
+ if (!sessionId || !isRecord(source) || source.kind !== 'goal') return false
265
+ const round = nonNegativeInteger(source.round)
266
+ const goalId = sessionIdOf(source.goalId)
267
+ if (round === undefined || round < 1 || !goalId) return false
268
+ let state = runs.get(sessionId)
269
+ const revision = nonNegativeInteger(source.revision)
270
+ if (!state) {
271
+ // Plugin loaded mid-goal: start from the previous round so only this
272
+ // segment's admitted rounds are counted.
273
+ state = {
274
+ goalId,
275
+ revision,
276
+ phase: 'active',
277
+ active: true,
278
+ armed: true,
279
+ baselineRounds: Math.max(0, round - 1),
280
+ lastRounds: round,
281
+ pending: null,
282
+ timer: null,
283
+ closed: false,
284
+ terminal: false,
285
+ turnActive: activeTurns.has(sessionId),
286
+ awaitingTurnEnd: false,
287
+ accountedTurn: null,
288
+ deliveryOff: !deliveryEnabled(),
289
+ }
290
+ runs.set(sessionId, state)
291
+ return true
292
+ }
293
+ if (state.closed) return false
294
+ if (state.goalId && state.goalId !== goalId) return false
295
+ if (revision !== undefined && state.revision !== undefined && revision < state.revision) return false
296
+ state.goalId = goalId
297
+ state.revision = revision ?? state.revision
298
+ state.turnActive = state.turnActive || activeTurns.has(sessionId)
299
+ state.lastRounds = Math.max(nonNegativeInteger(state.lastRounds) ?? 0, round)
300
+ if (state.deliveryOff && deliveryEnabled()) {
301
+ state.deliveryOff = false
302
+ state.pending = null
303
+ state.baselineRounds = Math.max(0, round - 1)
304
+ }
305
+ return true
306
+ }
307
+
308
+ /** Track the beginning of a root turn, so a terminal goal event can wait for its final turn. */
309
+ function onTurnStart(sessionId) {
310
+ if (!sessionId) return false
311
+ activeTurns.add(sessionId)
312
+ const state = runs.get(sessionId)
313
+ if (state && !state.closed) state.turnActive = true
314
+ return true
315
+ }
316
+
317
+ /** A failed/aborted turn is not buffered, but it must release a terminal wait. */
318
+ function onTurnAbort(sessionId) {
319
+ if (!sessionId) return false
320
+ activeTurns.delete(sessionId)
321
+ const state = runs.get(sessionId)
322
+ if (!state) return false
323
+ state.turnActive = false
324
+ if (state.terminal && !state.closed) {
325
+ state.awaitingTurnEnd = false
326
+ schedule(sessionId, state)
327
+ }
328
+ return true
329
+ }
330
+
331
+ /** Process goal/activation-changed (e.g. session resume disarms an active goal). */
332
+ function onActivation(payload) {
333
+ if (!isRecord(payload)) return false
334
+ const sessionId = sessionIdOf(payload.sessionId)
335
+ if (!sessionId) return false
336
+ const state = runs.get(sessionId)
337
+ if (!state || state.closed) return false
338
+ const goal = isRecord(payload.goal) ? payload.goal : null
339
+ if (goal && typeof goal.id === 'string' && state.goalId && goal.id !== state.goalId) {
340
+ return false
341
+ }
342
+ const activation = goal && typeof goal.activation === 'string' ? goal.activation : ''
343
+ if (activation === 'armed') {
344
+ state.armed = true
345
+ if (state.phase === 'active' && !state.terminal) state.active = true
346
+ return true
347
+ }
348
+ if (activation === 'disarmed' && state.active && !state.terminal) {
349
+ state.active = false
350
+ state.armed = false
351
+ state.terminal = true
352
+ if (state.phase === 'active') state.phase = 'paused'
353
+ if (state.turnActive) {
354
+ state.awaitingTurnEnd = true
355
+ } else {
356
+ schedule(sessionId, state)
357
+ }
358
+ return true
359
+ }
360
+ return true
361
+ }
362
+
363
+ /**
364
+ * Buffer or release one completed root turn.
365
+ *
366
+ * @returns {boolean} true when the caller must not publish a per-turn completion.
367
+ */
368
+ function onTurnEnd(sessionId, completion = {}) {
369
+ if (!sessionId) return false
370
+ activeTurns.delete(sessionId)
371
+ const state = runs.get(sessionId)
372
+ if (!state) return false
373
+ const turnKey = normalizedKey(completion.turnKey)
374
+ state.turnActive = false
375
+
376
+ // Observation continues while delivery is disabled, but a disabled run is
377
+ // never retroactively summarized. Re-enabling starts a new accounting
378
+ // segment at the current round, not at its completion.
379
+ if (!deliveryEnabled()) {
380
+ if (state.timer !== null) cancelTimer(state)
381
+ state.deliveryOff = true
382
+ state.pending = null
383
+ if (state.lastRounds !== undefined) state.baselineRounds = state.lastRounds
384
+ if (state.terminal) close(state, null)
385
+ return false
386
+ }
387
+
388
+ if (state.deliveryOff) {
389
+ state.deliveryOff = false
390
+ state.pending = null
391
+ state.baselineRounds =
392
+ state.lastRounds === undefined ? state.baselineRounds : Math.max(0, state.lastRounds - 1)
393
+ }
394
+
395
+ if (state.closed) {
396
+ // Suppress only a duplicate of the exact turn already accounted for.
397
+ if (turnKey !== undefined && state.accountedTurn === turnKey) return true
398
+ runs.delete(sessionId)
399
+ return false
400
+ }
401
+ if (!state.active && state.timer === null && !state.terminal) return false
402
+
403
+ state.pending = {
404
+ text: typeof completion.text === 'string' && completion.text.length > 0 ? completion.text : null,
405
+ sessionTitle: typeof completion.sessionTitle === 'string' ? completion.sessionTitle : undefined,
406
+ turnKey: turnKey ?? null,
407
+ at: now(),
408
+ }
409
+ if (!state.active) {
410
+ state.awaitingTurnEnd = false
411
+ schedule(sessionId, state)
412
+ }
413
+ return true
414
+ }
415
+
416
+ function dispose() {
417
+ for (const state of runs.values()) cancelTimer(state)
418
+ runs.clear()
419
+ activeTurns.clear()
420
+ }
421
+
422
+ return {
423
+ onGoalChange,
424
+ onGoalRound,
425
+ onTurnStart,
426
+ onTurnAbort,
427
+ onActivation,
428
+ onTurnEnd,
429
+ dispose,
430
+ snapshot() {
431
+ return Array.from(runs.entries()).map(([sessionId, state]) => ({
432
+ sessionId,
433
+ ...state,
434
+ pending: state.pending ? { ...state.pending } : null,
435
+ }))
436
+ },
437
+ }
438
+ }