@raidiant/notifai 3.0.1 → 5.0.0
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/CHANGELOG.md +73 -0
- package/README.md +12 -4
- package/dist/commands.d.ts +27 -10
- package/dist/commands.js +429 -152
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +20 -11
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +84 -12
- package/dist/hooks.js +433 -125
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +84 -49
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +38 -7
- package/dist/send.js +97 -41
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +501 -63
- package/src/commands.ts +547 -187
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +22 -11
- package/src/config.test.ts +178 -18
- package/src/config.ts +14 -1
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +500 -58
- package/src/hooks.ts +523 -169
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +119 -54
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +144 -50
- package/src/skill.test.ts +197 -120
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/src/send.ts
CHANGED
|
@@ -2,16 +2,23 @@ import {
|
|
|
2
2
|
CLI_SOUNDS,
|
|
3
3
|
COLLAPSE_KEY_MAX_BYTES,
|
|
4
4
|
INTERRUPTION_LEVELS,
|
|
5
|
+
MEDIA_MAX_ITEMS,
|
|
5
6
|
NOTIFICATION_SCHEMA_VERSION,
|
|
6
7
|
QUESTION_TEXT_MAX_LENGTH,
|
|
8
|
+
bannerExcerpt,
|
|
7
9
|
type IosOptionsT,
|
|
8
10
|
type LifecycleT,
|
|
9
11
|
type MacosOptionsT,
|
|
12
|
+
type MediaItemT,
|
|
10
13
|
type NotificationDraftT,
|
|
11
14
|
NOTIFICATION_KINDS,
|
|
15
|
+
DEFAULT_NOTIFICATION_KIND,
|
|
16
|
+
defaultSoundForKind,
|
|
17
|
+
type NotificationKind,
|
|
12
18
|
PLATFORMS,
|
|
13
19
|
type Platform,
|
|
14
20
|
type QuestionT,
|
|
21
|
+
type SourceContextT,
|
|
15
22
|
type SubmissionReceipt,
|
|
16
23
|
} from '@raidiant/notifai-protocol'
|
|
17
24
|
import type { CliConfig } from './config.js'
|
|
@@ -20,15 +27,17 @@ type CliSound = (typeof CLI_SOUNDS)[number]
|
|
|
20
27
|
|
|
21
28
|
export interface SendFlags {
|
|
22
29
|
title: string
|
|
30
|
+
/** The one canonical Markdown body. */
|
|
23
31
|
body: string
|
|
24
32
|
subtitle?: string
|
|
25
|
-
/** Long-form markdown for the app's detail view; never on the banner. */
|
|
26
|
-
detail?: string
|
|
27
33
|
event?: string
|
|
28
|
-
/** What this notification is
|
|
34
|
+
/** What this notification is; absent means update. */
|
|
29
35
|
kind?: string
|
|
30
36
|
project?: string
|
|
31
|
-
session
|
|
37
|
+
/** Opaque exact-session override. */
|
|
38
|
+
sessionId?: string
|
|
39
|
+
/** Human-readable session label override. */
|
|
40
|
+
sessionLabel?: string
|
|
32
41
|
device?: string[]
|
|
33
42
|
all?: boolean
|
|
34
43
|
ttl?: number
|
|
@@ -37,7 +46,15 @@ export interface SendFlags {
|
|
|
37
46
|
threadId?: string
|
|
38
47
|
level?: string
|
|
39
48
|
data?: string[]
|
|
40
|
-
|
|
49
|
+
/** Ready media ids in author-supplied order. Commands resolve paths and URLs first. */
|
|
50
|
+
image?: string[]
|
|
51
|
+
/** Optional alt text paired with images by position. */
|
|
52
|
+
imageAlt?: string[]
|
|
53
|
+
/**
|
|
54
|
+
* Complete canonical media manifest for internal callers that already froze
|
|
55
|
+
* uploads and alt text. Mutually exclusive with the CLI's parallel inputs.
|
|
56
|
+
*/
|
|
57
|
+
media?: MediaItemT[]
|
|
41
58
|
/** Enable the inline reply action for this Notification Request. */
|
|
42
59
|
reply?: boolean
|
|
43
60
|
/** How long the server accepts a reply after submission. */
|
|
@@ -67,33 +84,123 @@ export type DraftBuild =
|
|
|
67
84
|
| { ok: true; draft: NotificationDraftT; platform: Platform }
|
|
68
85
|
| { ok: false; error: string }
|
|
69
86
|
|
|
87
|
+
/** Invocation-derived values beneath explicit and configured author input. */
|
|
88
|
+
export interface DraftInvocation {
|
|
89
|
+
inferredProject?: string | null
|
|
90
|
+
source?: SourceContextT
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const POSITIONAL_MEDIA_REFERENCE = /media:(\d+)(?![A-Za-z0-9_-])/g
|
|
94
|
+
|
|
95
|
+
/** Validate media cardinality and positional alt pairing before any upload starts. */
|
|
96
|
+
export function validateMediaInputs(
|
|
97
|
+
images: readonly string[] | undefined,
|
|
98
|
+
alts: readonly string[] | undefined,
|
|
99
|
+
): string | null {
|
|
100
|
+
const imageCount = images?.length ?? 0
|
|
101
|
+
const imageAlts = alts ?? []
|
|
102
|
+
if (imageCount > MEDIA_MAX_ITEMS) return `Attach at most ${MEDIA_MAX_ITEMS} images.`
|
|
103
|
+
if (imageAlts.length > imageCount) {
|
|
104
|
+
return `Received ${imageAlts.length} --image-alt values for ${imageCount} --image values.`
|
|
105
|
+
}
|
|
106
|
+
if (imageAlts.some((alt) => alt.length < 1 || alt.length > 256)) {
|
|
107
|
+
return '--image-alt must be 1-256 characters.'
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Validate a frozen value-object manifest without collapsing sparse alt text. */
|
|
113
|
+
function validateMediaManifest(media: readonly MediaItemT[]): string | null {
|
|
114
|
+
if (media.length > MEDIA_MAX_ITEMS) return `Attach at most ${MEDIA_MAX_ITEMS} images.`
|
|
115
|
+
if (media.some((item) => item.alt !== undefined && (item.alt.length < 1 || item.alt.length > 256))) {
|
|
116
|
+
return 'Media alt text must be 1-256 characters.'
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Rewrite authorable 1-based media positions to canonical ready media ids. */
|
|
122
|
+
export function rewriteMediaReferences(
|
|
123
|
+
body: string,
|
|
124
|
+
mediaIds: readonly string[],
|
|
125
|
+
): { ok: true; body: string } | { ok: false; error: string } {
|
|
126
|
+
let error: string | undefined
|
|
127
|
+
const rewritten = body.replace(POSITIONAL_MEDIA_REFERENCE, (reference, digits: string) => {
|
|
128
|
+
const position = Number(digits)
|
|
129
|
+
if (
|
|
130
|
+
!Number.isSafeInteger(position) ||
|
|
131
|
+
position < 1 ||
|
|
132
|
+
position > MEDIA_MAX_ITEMS ||
|
|
133
|
+
digits !== String(position) ||
|
|
134
|
+
mediaIds[position - 1] === undefined
|
|
135
|
+
) {
|
|
136
|
+
error = `Body reference "${reference}" has no matching --image occurrence.`
|
|
137
|
+
return reference
|
|
138
|
+
}
|
|
139
|
+
return `media:${mediaIds[position - 1]}`
|
|
140
|
+
})
|
|
141
|
+
return error === undefined ? { ok: true, body: rewritten } : { ok: false, error }
|
|
142
|
+
}
|
|
143
|
+
|
|
70
144
|
/** Merge flags over resolved config into a Notification Request draft. */
|
|
71
|
-
export function buildDraft(
|
|
145
|
+
export function buildDraft(
|
|
146
|
+
config: CliConfig,
|
|
147
|
+
flags: SendFlags,
|
|
148
|
+
invocation: DraftInvocation = {},
|
|
149
|
+
): DraftBuild {
|
|
72
150
|
if (!flags.title || !flags.body) return { ok: false, error: 'Both --title and --body are required.' }
|
|
73
151
|
const platform = resolvePlatform(flags.platform)
|
|
74
152
|
if (!platform) {
|
|
75
153
|
return { ok: false, error: `Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.` }
|
|
76
154
|
}
|
|
77
155
|
|
|
156
|
+
if (flags.media !== undefined && (flags.image !== undefined || flags.imageAlt !== undefined)) {
|
|
157
|
+
return { ok: false, error: 'Internal media manifests cannot be combined with --image or --image-alt.' }
|
|
158
|
+
}
|
|
159
|
+
const imageIds = flags.media?.map((item) => item.media_id) ?? flags.image ?? []
|
|
160
|
+
const imageAlts = flags.imageAlt ?? []
|
|
161
|
+
const mediaInputError =
|
|
162
|
+
flags.media === undefined
|
|
163
|
+
? validateMediaInputs(imageIds, imageAlts)
|
|
164
|
+
: validateMediaManifest(flags.media)
|
|
165
|
+
if (mediaInputError !== null) return { ok: false, error: mediaInputError }
|
|
166
|
+
const media =
|
|
167
|
+
flags.media ??
|
|
168
|
+
imageIds.map((media_id, index) => ({
|
|
169
|
+
media_id,
|
|
170
|
+
...(imageAlts[index] !== undefined ? { alt: imageAlts[index] } : {}),
|
|
171
|
+
}))
|
|
172
|
+
const body = rewriteMediaReferences(flags.body, imageIds)
|
|
173
|
+
if (!body.ok) return body
|
|
174
|
+
|
|
78
175
|
const deviceIds = flags.all ? null : (flags.device?.length ? flags.device : config.devices.value)
|
|
79
176
|
const targets: NotificationDraftT['targets'] =
|
|
80
177
|
deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' }
|
|
81
178
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
179
|
+
if (
|
|
180
|
+
flags.kind !== undefined &&
|
|
181
|
+
!NOTIFICATION_KINDS.includes(flags.kind as (typeof NOTIFICATION_KINDS)[number])
|
|
182
|
+
) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: `Unknown kind "${flags.kind}" — supported: ${NOTIFICATION_KINDS.map((kind) => `"${kind}"`).join(', ')}.`,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Kind states what happened; the sound table turns that truth into the
|
|
190
|
+
// attention it deserves, so an honest sender gets the right sound without
|
|
191
|
+
// reasoning about audio. An explicit --sound and the user's saved preference
|
|
192
|
+
// both outrank it, and `none` still means silent.
|
|
193
|
+
const kindForSound: NotificationKind = flags.reply
|
|
194
|
+
? 'question'
|
|
195
|
+
: ((flags.kind as NotificationKind | undefined) ?? DEFAULT_NOTIFICATION_KIND)
|
|
196
|
+
const sound = flags.sound ?? config.sound.value ?? defaultSoundForKind(kindForSound)
|
|
86
197
|
if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound as CliSound)) {
|
|
87
198
|
return {
|
|
88
199
|
ok: false,
|
|
89
200
|
error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
|
|
90
201
|
}
|
|
91
202
|
}
|
|
92
|
-
const
|
|
93
|
-
config.interruption_level.source === 'default'
|
|
94
|
-
? undefined
|
|
95
|
-
: config.interruption_level.value
|
|
96
|
-
const level = flags.level ?? configuredLevel ?? profile?.level
|
|
203
|
+
const level = flags.level ?? config.interruption_level.value
|
|
97
204
|
if (
|
|
98
205
|
level !== null &&
|
|
99
206
|
level !== undefined &&
|
|
@@ -121,16 +228,6 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
121
228
|
options.custom_data = data
|
|
122
229
|
}
|
|
123
230
|
|
|
124
|
-
if (
|
|
125
|
-
flags.kind !== undefined &&
|
|
126
|
-
!NOTIFICATION_KINDS.includes(flags.kind as (typeof NOTIFICATION_KINDS)[number])
|
|
127
|
-
) {
|
|
128
|
-
return {
|
|
129
|
-
ok: false,
|
|
130
|
-
error: `Unknown kind "${flags.kind}" — supported: ${NOTIFICATION_KINDS.map((kind) => `"${kind}"`).join(', ')}.`,
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
231
|
const ttl = flags.ttl ?? config.ttl_seconds.value
|
|
135
232
|
const collapse = flags.collapseKey ?? config.collapse_key.value
|
|
136
233
|
if (
|
|
@@ -144,10 +241,7 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
144
241
|
}
|
|
145
242
|
}
|
|
146
243
|
|
|
147
|
-
const project = flags.project ?? config.project.value
|
|
148
|
-
// Harnesses can export NOTIFAI_SESSION once instead of passing the flag
|
|
149
|
-
// on every send.
|
|
150
|
-
const session = flags.session ?? process.env['NOTIFAI_SESSION']
|
|
244
|
+
const project = flags.project ?? config.project.value ?? invocation.inferredProject
|
|
151
245
|
|
|
152
246
|
// Ids are derived from labels so an agent writing a one-liner does not have
|
|
153
247
|
// to invent them, but they stay the stable thing it branches on afterwards.
|
|
@@ -155,25 +249,25 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
155
249
|
if (choices === 'invalid') {
|
|
156
250
|
return {
|
|
157
251
|
ok: false,
|
|
158
|
-
error:
|
|
252
|
+
error: REPLY_CHOICE_USAGE,
|
|
159
253
|
}
|
|
160
254
|
}
|
|
161
255
|
if (flags.replyMulti && choices === null && flags.questions === undefined) {
|
|
162
256
|
return { ok: false, error: '--reply-multi needs answers to select between; add --reply-choice.' }
|
|
163
257
|
}
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
258
|
+
// A single reply question is the first readable block of the canonical body.
|
|
259
|
+
// Context may follow after a blank line without changing what the user answers.
|
|
260
|
+
const derivedQuestion = bannerExcerpt(body.body).split('\n', 1)[0]!.trim()
|
|
167
261
|
if (
|
|
168
262
|
flags.reply &&
|
|
169
263
|
flags.questions === undefined &&
|
|
170
|
-
|
|
264
|
+
derivedQuestion.length > QUESTION_TEXT_MAX_LENGTH
|
|
171
265
|
) {
|
|
172
266
|
return {
|
|
173
267
|
ok: false,
|
|
174
268
|
error:
|
|
175
|
-
`
|
|
176
|
-
'
|
|
269
|
+
`Keep the question within ${QUESTION_TEXT_MAX_LENGTH} characters and put ` +
|
|
270
|
+
'the longer context after a blank line — the first paragraph is the question.',
|
|
177
271
|
}
|
|
178
272
|
}
|
|
179
273
|
|
|
@@ -185,26 +279,23 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
185
279
|
: {}),
|
|
186
280
|
...(flags.lifecycle !== undefined ? { lifecycle: flags.lifecycle } : {}),
|
|
187
281
|
...(project !== null && project !== undefined ? { project } : {}),
|
|
188
|
-
...(
|
|
282
|
+
...(invocation.source !== undefined ? { source: invocation.source } : {}),
|
|
189
283
|
presentation: {
|
|
190
284
|
title: flags.title,
|
|
191
|
-
body:
|
|
285
|
+
body: body.body,
|
|
192
286
|
...(flags.subtitle !== undefined ? { subtitle: flags.subtitle } : {}),
|
|
193
|
-
...(
|
|
194
|
-
? { detail: flags.detail }
|
|
195
|
-
: {}),
|
|
196
|
-
...(flags.image !== undefined ? { image: { media_id: flags.image } } : {}),
|
|
287
|
+
...(media.length > 0 ? { media } : {}),
|
|
197
288
|
},
|
|
198
289
|
targets,
|
|
199
290
|
delivery: { ttl_seconds: ttl, collapse_key: collapse },
|
|
200
291
|
...(flags.reply
|
|
201
292
|
? {
|
|
202
293
|
reply: {
|
|
203
|
-
expires_in_seconds: flags.replyWindow ??
|
|
294
|
+
expires_in_seconds: flags.replyWindow ?? config.reply_window_seconds.value,
|
|
204
295
|
questions: flags.questions ?? [
|
|
205
296
|
{
|
|
206
297
|
id: 'q1',
|
|
207
|
-
text:
|
|
298
|
+
text: derivedQuestion,
|
|
208
299
|
...(choices !== null ? { choices } : {}),
|
|
209
300
|
...(flags.replyMulti ? { multi: true } : {}),
|
|
210
301
|
},
|
|
@@ -223,12 +314,6 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
223
314
|
return { ok: true, draft, platform }
|
|
224
315
|
}
|
|
225
316
|
|
|
226
|
-
const KIND_PROFILES = {
|
|
227
|
-
update: { sound: 'none', level: 'passive' },
|
|
228
|
-
done: { sound: 'done', level: 'passive' },
|
|
229
|
-
question: { sound: 'attention', level: 'active' },
|
|
230
|
-
} as const
|
|
231
|
-
|
|
232
317
|
function resolvePlatform(value: string | undefined): Platform | null {
|
|
233
318
|
if (value === undefined) return 'ios'
|
|
234
319
|
return (PLATFORMS as readonly string[]).includes(value) ? (value as Platform) : null
|
|
@@ -284,10 +369,19 @@ export function parseChoices(
|
|
|
284
369
|
}
|
|
285
370
|
|
|
286
371
|
/** Message shared by every surface that accepts choice labels. */
|
|
372
|
+
/** `ask` spells this flag `--choice`. */
|
|
287
373
|
export const CHOICE_USAGE =
|
|
288
374
|
'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
|
|
289
375
|
'e.g. --choice "Yes, ship it" --choice "Not yet".'
|
|
290
376
|
|
|
377
|
+
/**
|
|
378
|
+
* `send` spells the same flag `--reply-choice`, and a remedy that names the
|
|
379
|
+
* other command's flag reproduces the error it is trying to fix.
|
|
380
|
+
*/
|
|
381
|
+
export const REPLY_CHOICE_USAGE =
|
|
382
|
+
'Offer 2-6 answers, one --reply-choice flag per answer, unique once slugified — ' +
|
|
383
|
+
'e.g. --reply-choice "Yes, ship it" --reply-choice "Not yet".'
|
|
384
|
+
|
|
291
385
|
export function slugify(label: string): string {
|
|
292
386
|
return label
|
|
293
387
|
.toLowerCase()
|