@raidiant/notifai 0.1.7 → 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.
- package/README.md +9 -2
- package/dist/atomic-file.d.ts +12 -0
- package/dist/atomic-file.js +55 -0
- package/dist/atomic-file.js.map +1 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +17 -4
- package/dist/commands.js +643 -212
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +20 -7
- package/dist/hooks.js +472 -149
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +8 -4
- package/dist/install-hooks.js +45 -64
- package/dist/install-hooks.js.map +1 -1
- package/dist/main.js +45 -33
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +31 -0
- package/dist/native-skills.js +117 -0
- package/dist/native-skills.js.map +1 -0
- package/dist/opencode-plugin.d.ts +1 -0
- package/dist/opencode-plugin.js +37 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +0 -3
- package/dist/send.js +28 -13
- package/dist/send.js.map +1 -1
- package/package.json +12 -12
- package/src/atomic-file.ts +73 -0
- package/src/client.ts +3 -0
- package/src/commands.test.ts +932 -63
- package/src/commands.ts +780 -225
- package/src/config.test.ts +68 -6
- package/src/hooks.test.ts +398 -5
- package/src/hooks.ts +557 -161
- package/src/install-hooks.test.ts +36 -1
- package/src/install-hooks.ts +50 -68
- package/src/main.ts +54 -37
- package/src/native-skills.ts +177 -0
- package/src/opencode-plugin.ts +38 -5
- package/src/send.ts +32 -12
- package/src/skill.test.ts +43 -0
package/src/opencode-plugin.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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 ??
|
|
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 ===
|
|
171
|
-
? { platform: { ios: options } }
|
|
172
|
-
:
|
|
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
|
+
})
|