@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +12 -4
  3. package/dist/commands.d.ts +27 -10
  4. package/dist/commands.js +429 -152
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +20 -11
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +12 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/file-lock.d.ts +6 -1
  12. package/dist/file-lock.js +55 -9
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/harnesses.d.ts +1 -1
  15. package/dist/harnesses.js.map +1 -1
  16. package/dist/hooks.d.ts +84 -12
  17. package/dist/hooks.js +433 -125
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +91 -14
  20. package/dist/install-hooks.js +226 -34
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/interactive.d.ts +1 -1
  23. package/dist/interactive.js +1 -1
  24. package/dist/invocation-context.d.ts +42 -0
  25. package/dist/invocation-context.js +130 -0
  26. package/dist/invocation-context.js.map +1 -0
  27. package/dist/logging.d.ts +8 -0
  28. package/dist/logging.js +8 -0
  29. package/dist/logging.js.map +1 -1
  30. package/dist/main.js +84 -49
  31. package/dist/main.js.map +1 -1
  32. package/dist/opencode-plugin.d.ts +8 -0
  33. package/dist/opencode-plugin.js +24 -162
  34. package/dist/opencode-plugin.js.map +1 -1
  35. package/dist/send.d.ts +38 -7
  36. package/dist/send.js +97 -41
  37. package/dist/send.js.map +1 -1
  38. package/dist/ui/help.d.ts +0 -1
  39. package/dist/ui/help.js +6 -3
  40. package/dist/ui/help.js.map +1 -1
  41. package/dist/ui/theme.d.ts +0 -15
  42. package/dist/ui/theme.js +2 -18
  43. package/dist/ui/theme.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/commands.test.ts +501 -63
  46. package/src/commands.ts +547 -187
  47. package/src/config-schema.test.ts +1 -1
  48. package/src/config-schema.ts +22 -11
  49. package/src/config.test.ts +178 -18
  50. package/src/config.ts +14 -1
  51. package/src/file-lock.test.ts +41 -0
  52. package/src/file-lock.ts +59 -9
  53. package/src/harnesses.test.ts +37 -29
  54. package/src/harnesses.ts +1 -4
  55. package/src/hooks.test.ts +500 -58
  56. package/src/hooks.ts +523 -169
  57. package/src/install-hooks.test.ts +232 -12
  58. package/src/install-hooks.ts +241 -37
  59. package/src/interactive.ts +1 -1
  60. package/src/invocation-context.test.ts +253 -0
  61. package/src/invocation-context.ts +176 -0
  62. package/src/logging-process-worker.test.ts +8 -2
  63. package/src/logging.test.ts +33 -18
  64. package/src/logging.ts +11 -0
  65. package/src/main.ts +119 -54
  66. package/src/opencode-plugin.ts +24 -164
  67. package/src/question-caps.test.ts +49 -0
  68. package/src/send.ts +144 -50
  69. package/src/skill.test.ts +197 -120
  70. package/src/ui/help.ts +8 -3
  71. 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: update (default), done, or question. */
34
+ /** What this notification is; absent means update. */
29
35
  kind?: string
30
36
  project?: string
31
- session?: string
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
- image?: string
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(config: CliConfig, flags: SendFlags): DraftBuild {
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
- const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update')
83
- const profile = KIND_PROFILES[effectiveKind as keyof typeof KIND_PROFILES]
84
- const configuredSound = config.sound.source === 'default' ? undefined : config.sound.value
85
- const sound = flags.sound ?? configuredSound ?? profile?.sound
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 configuredLevel =
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: CHOICE_USAGE,
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
- // The banner body doubles as the question text on a single-question send, so
165
- // it inherits the question's readable-in-full bound; context beyond that
166
- // belongs in --detail, which never rides the banner.
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
- flags.body.length > QUESTION_TEXT_MAX_LENGTH
264
+ derivedQuestion.length > QUESTION_TEXT_MAX_LENGTH
171
265
  ) {
172
266
  return {
173
267
  ok: false,
174
268
  error:
175
- `A question must be readable where it is answered: keep it within ${QUESTION_TEXT_MAX_LENGTH} ` +
176
- 'characters and put the longer context in --detail.',
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
- ...(session !== undefined && session !== '' ? { session } : {}),
282
+ ...(invocation.source !== undefined ? { source: invocation.source } : {}),
189
283
  presentation: {
190
284
  title: flags.title,
191
- body: flags.body,
285
+ body: body.body,
192
286
  ...(flags.subtitle !== undefined ? { subtitle: flags.subtitle } : {}),
193
- ...(flags.detail !== undefined && flags.detail.trim() !== ''
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 ?? 3600,
294
+ expires_in_seconds: flags.replyWindow ?? config.reply_window_seconds.value,
204
295
  questions: flags.questions ?? [
205
296
  {
206
297
  id: 'q1',
207
- text: flags.body,
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()