@raidiant/notifai 0.1.6 → 0.1.8

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.
@@ -30,7 +30,7 @@ export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
30
30
  export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
31
31
 
32
32
  /** Bump when an installed generated file must be rewritten to remain functional. */
33
- const OPENCODE_ADAPTER_VERSION = 2
33
+ const OPENCODE_ADAPTER_VERSION = 4
34
34
 
35
35
  export function opencodePluginDir(
36
36
  global: boolean,
@@ -88,7 +88,7 @@ function runHook(event, envelope) {
88
88
  return new Promise((resolve) => {
89
89
  let child
90
90
  try {
91
- child = spawn(EXEC, [SCRIPT, "hook", event, "--owner", "notifai"], {
91
+ child = spawn(EXEC, [SCRIPT, "hook", event, "--owner", "notifai", "--harness", "opencode"], {
92
92
  env: process.env,
93
93
  shell: false,
94
94
  stdio: ["pipe", "pipe", "pipe"],
@@ -128,20 +128,51 @@ function runHook(event, envelope) {
128
128
  })
129
129
  }
130
130
 
131
+ function additionalContext(stdout) {
132
+ if (typeof stdout !== "string" || stdout.trim() === "") return null
133
+ try {
134
+ const parsed = JSON.parse(stdout)
135
+ const context = parsed?.hookSpecificOutput?.additionalContext
136
+ return typeof context === "string" && context.length > 0 ? context : null
137
+ } catch {
138
+ return null
139
+ }
140
+ }
141
+
131
142
  export const NotifAIPlugin = async ({ directory }) => {
132
143
  const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
133
144
 
134
145
  return {
146
+ /** Exact active-harness identity for shell commands such as \`notifai ask\`. */
147
+ "shell.env": async (input, output) => {
148
+ output.env.NOTIFAI_ACTIVE_HARNESS = "opencode"
149
+ const sessionID = input?.sessionID
150
+ if (typeof sessionID === "string" && sessionID.length > 0) {
151
+ output.env.NOTIFAI_ACTIVE_SESSION_ID = sessionID
152
+ }
153
+ },
154
+
135
155
  /**
136
156
  * Presence. The user typing is the signal every harness shares, and it is
137
157
  * also what publishes the project -> session pointer \`notifai ask\` reads.
138
158
  */
139
- "chat.message": async (input) => {
140
- await runHook("user-prompt-submit", {
159
+ "chat.message": async (input, output) => {
160
+ const stdout = await runHook("user-prompt-submit", {
141
161
  session_id: input?.sessionID ?? input?.info?.sessionID,
142
162
  cwd,
143
163
  hook_event_name: "UserPromptSubmit",
144
164
  })
165
+ const context = additionalContext(stdout)
166
+ if (context !== null) {
167
+ output.parts.push({
168
+ id: "prt_notifai_" + Date.now(),
169
+ sessionID: input.sessionID,
170
+ messageID: output.message.id,
171
+ type: "text",
172
+ text: context,
173
+ synthetic: true,
174
+ })
175
+ }
145
176
  },
146
177
 
147
178
  /** Session lifecycle events are delivered through OpenCode's event bus. */
@@ -185,17 +216,19 @@ export function isOurOpencodePlugin(contents: string): boolean {
185
216
  */
186
217
  export function opencodePluginTarget(
187
218
  contents: string,
188
- ): { exec: string; script: string; current: boolean } | null {
219
+ ): { exec: string; script: string; current: boolean; timeoutSeconds?: number } | null {
189
220
  if (!isOurOpencodePlugin(contents)) return null
190
221
  const exec = /^const EXEC = (".*")$/m.exec(contents)?.[1]
191
222
  const script = /^const SCRIPT = (".*")$/m.exec(contents)?.[1]
192
223
  const version = Number(/^const ADAPTER_VERSION = (\d+)$/m.exec(contents)?.[1] ?? 1)
224
+ const timeoutMs = Number(/^const TIMEOUT_MS = (\d+)$/m.exec(contents)?.[1] ?? Number.NaN)
193
225
  if (exec === undefined || script === undefined) return null
194
226
  try {
195
227
  return {
196
228
  exec: JSON.parse(exec) as string,
197
229
  script: JSON.parse(script) as string,
198
230
  current: version === OPENCODE_ADAPTER_VERSION,
231
+ ...(Number.isFinite(timeoutMs) ? { timeoutSeconds: timeoutMs / 1000 } : {}),
199
232
  }
200
233
  } catch {
201
234
  return null
package/src/send.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  CLI_SOUNDS,
3
+ COLLAPSE_KEY_MAX_BYTES,
3
4
  INTERRUPTION_LEVELS,
4
5
  NOTIFICATION_SCHEMA_VERSION,
5
6
  type IosOptionsT,
@@ -31,11 +32,8 @@ export interface SendFlags {
31
32
  ttl?: number
32
33
  collapseKey?: string
33
34
  sound?: string
34
- badge?: number
35
35
  threadId?: string
36
36
  level?: string
37
- relevance?: number
38
- targetContentId?: string
39
37
  data?: string[]
40
38
  image?: string
41
39
  /** Enable the inline reply action for this Notification Request. */
@@ -72,14 +70,21 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
72
70
  const targets: NotificationDraftT['targets'] =
73
71
  deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' }
74
72
 
75
- const sound = flags.sound ?? config.sound.value
73
+ const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update')
74
+ const profile = KIND_PROFILES[effectiveKind as keyof typeof KIND_PROFILES]
75
+ const configuredSound = config.sound.source === 'default' ? undefined : config.sound.value
76
+ const sound = flags.sound ?? configuredSound ?? profile?.sound
76
77
  if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound as CliSound)) {
77
78
  return {
78
79
  ok: false,
79
80
  error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
80
81
  }
81
82
  }
82
- const level = flags.level ?? config.interruption_level.value
83
+ const configuredLevel =
84
+ config.interruption_level.source === 'default'
85
+ ? undefined
86
+ : config.interruption_level.value
87
+ const level = flags.level ?? configuredLevel ?? profile?.level
83
88
  if (
84
89
  level !== null &&
85
90
  level !== undefined &&
@@ -93,13 +98,10 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
93
98
  else if (sound !== null && sound !== undefined) {
94
99
  options.sound = sound as Exclude<(typeof CLI_SOUNDS)[number], 'none'>
95
100
  }
96
- if (flags.badge !== undefined) options.badge = flags.badge
97
101
  if (flags.threadId !== undefined) options.thread_id = flags.threadId
98
102
  if (level !== null && level !== undefined) {
99
103
  options.interruption_level = level as (typeof INTERRUPTION_LEVELS)[number]
100
104
  }
101
- if (flags.relevance !== undefined) options.relevance_score = flags.relevance
102
- if (flags.targetContentId !== undefined) options.target_content_id = flags.targetContentId
103
105
  if (flags.data?.length) {
104
106
  const data: Record<string, string> = {}
105
107
  for (const pair of flags.data) {
@@ -122,6 +124,16 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
122
124
 
123
125
  const ttl = flags.ttl ?? config.ttl_seconds.value
124
126
  const collapse = flags.collapseKey ?? config.collapse_key.value
127
+ if (
128
+ collapse !== null &&
129
+ collapse !== undefined &&
130
+ Buffer.byteLength(collapse, 'utf8') > COLLAPSE_KEY_MAX_BYTES
131
+ ) {
132
+ return {
133
+ ok: false,
134
+ error: `--collapse-key must be at most ${COLLAPSE_KEY_MAX_BYTES} UTF-8 bytes.`,
135
+ }
136
+ }
125
137
 
126
138
  const project = flags.project ?? config.project.value
127
139
  // Harnesses can export NOTIFAI_SESSION once instead of passing the flag
@@ -161,20 +173,28 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
161
173
  ...(flags.reply
162
174
  ? {
163
175
  reply: {
164
- expires_in_seconds: flags.replyWindow ?? 86400,
176
+ expires_in_seconds: flags.replyWindow ?? 3600,
165
177
  ...(choices !== null ? { kind: 'choice' as const, choices } : {}),
166
178
  },
167
179
  }
168
180
  : {}),
169
181
  ...(Object.keys(options).length > 0
170
- ? platform === 'ios'
171
- ? { platform: { ios: options } }
172
- : { platform: { macos: options } }
182
+ ? flags.platform === undefined
183
+ ? { platform: { ios: options, macos: options } }
184
+ : platform === 'ios'
185
+ ? { platform: { ios: options } }
186
+ : { platform: { macos: options } }
173
187
  : {}),
174
188
  }
175
189
  return { ok: true, draft, platform }
176
190
  }
177
191
 
192
+ const KIND_PROFILES = {
193
+ update: { sound: 'none', level: 'passive' },
194
+ done: { sound: 'done', level: 'passive' },
195
+ question: { sound: 'attention', level: 'active' },
196
+ } as const
197
+
178
198
  function resolvePlatform(value: string | undefined): Platform | null {
179
199
  if (value === undefined) return 'ios'
180
200
  return (PLATFORMS as readonly string[]).includes(value) ? (value as Platform) : null
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ const skill = readFileSync(new URL('../../../skills/notifai/SKILL.md', import.meta.url), 'utf8')
5
+ const harnessReference = readFileSync(
6
+ new URL('../../../skills/notifai/references/harness-setup.md', import.meta.url),
7
+ 'utf8',
8
+ )
9
+
10
+ describe('Notifai agent skill', () => {
11
+ it('leads with the notification decision before message composition', () => {
12
+ expect(skill.indexOf('## When to notify')).toBeGreaterThan(0)
13
+ expect(skill.indexOf('## When to notify')).toBeLessThan(skill.indexOf('## Compose and send'))
14
+ })
15
+
16
+ it('teaches kind profiles and a correctly classified completion', () => {
17
+ expect(skill).toContain('--kind done')
18
+ expect(skill).toContain('| `update` (default) | `none` | `passive` |')
19
+ expect(skill).toContain('| `done` | `done` | `passive` |')
20
+ expect(skill).toContain('| question (`--reply`) | `attention` | `active` |')
21
+ })
22
+
23
+ it('documents reply lifecycle, routing, session minting, and doctor exits', () => {
24
+ for (const required of [
25
+ '--reply-choice',
26
+ 'notifai close <request_id>',
27
+ 'notifai devices',
28
+ '--device',
29
+ '--all',
30
+ "require('node:crypto').randomUUID()",
31
+ 'exit status is nonzero',
32
+ ]) {
33
+ expect(skill).toContain(required)
34
+ }
35
+ })
36
+
37
+ it('moves per-harness mechanics into progressive disclosure', () => {
38
+ expect(skill).toContain('[Harness setup and recovery](references/harness-setup.md)')
39
+ for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode']) {
40
+ expect(harnessReference).toContain(`**${harness}:**`)
41
+ }
42
+ })
43
+ })