@raidiant/notifai 7.0.2 → 8.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 +34 -0
- package/dist/commands-devices.d.ts +2 -1
- package/dist/commands-devices.js +20 -8
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.js +14 -12
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +23 -0
- package/dist/commands-guidance.js +128 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +7 -1
- package/dist/commands-hooks.js +3 -6
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +25 -22
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +5 -3
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +45 -28
- package/dist/commands-send.js.map +1 -1
- package/dist/commands.d.ts +1 -0
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +6 -15
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +1 -3
- package/dist/config.js +0 -3
- package/dist/config.js.map +1 -1
- package/dist/guidance-content.d.ts +30 -0
- package/dist/guidance-content.js +175 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +48 -0
- package/dist/guidance.js +118 -0
- package/dist/guidance.js.map +1 -0
- package/dist/hooks.js +7 -7
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +13 -8
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +3 -474
- package/dist/main.js.map +1 -1
- package/dist/program.d.ts +49 -0
- package/dist/program.js +571 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +4 -9
- package/dist/send.js +48 -28
- package/dist/send.js.map +1 -1
- package/dist/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- package/package.json +5 -3
- package/src/commands-devices.ts +28 -9
- package/src/commands-doctor.ts +17 -12
- package/src/commands-guidance.ts +170 -0
- package/src/commands-hooks.ts +10 -8
- package/src/commands-init.ts +31 -22
- package/src/commands-send-support.ts +5 -3
- package/src/commands-send.ts +50 -28
- package/src/commands.test.ts +374 -63
- package/src/commands.ts +5 -0
- package/src/config-schema.ts +6 -17
- package/src/config.test.ts +56 -1
- package/src/config.ts +0 -5
- package/src/guidance-content.ts +190 -0
- package/src/guidance.test.ts +268 -0
- package/src/guidance.ts +151 -0
- package/src/hooks.test.ts +37 -5
- package/src/hooks.ts +7 -7
- package/src/interactive.ts +13 -8
- package/src/main.ts +4 -600
- package/src/program.test.ts +202 -0
- package/src/program.ts +723 -0
- package/src/send.ts +53 -32
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +37 -12
package/src/guidance.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { globalConfigDir, personalProjectIdentity } from './config.js'
|
|
4
|
+
import { SHIPPED_GUIDANCE } from './guidance-content.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Layered notification-writing guidance with provenance. Most specific wins,
|
|
8
|
+
* per topic, exactly the discipline config keys follow:
|
|
9
|
+
*
|
|
10
|
+
* project-local > project > global > shipped default
|
|
11
|
+
*
|
|
12
|
+
* A topic is one Markdown file named `<topic>.md`. A user file replaces the
|
|
13
|
+
* shipped topic of the same name wholesale — their words outrank the default,
|
|
14
|
+
* not annotate it — and a file whose name matches no shipped topic is purely
|
|
15
|
+
* additive house rules. There is no session layer: an instruction given in
|
|
16
|
+
* conversation tunes the session by being followed, not persisted.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type GuidanceSource =
|
|
20
|
+
| `project-local:${string}`
|
|
21
|
+
| `project:${string}`
|
|
22
|
+
| `global:${string}`
|
|
23
|
+
| 'default'
|
|
24
|
+
|
|
25
|
+
export interface ResolvedGuidanceTopic {
|
|
26
|
+
name: string
|
|
27
|
+
source: GuidanceSource
|
|
28
|
+
/** One line for lists; user-authored topics describe themselves by content. */
|
|
29
|
+
summary: string
|
|
30
|
+
content: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Topic names double as filenames, and the project layer arrives from a
|
|
35
|
+
* repository — untrusted input. Anything that does not match is not a topic,
|
|
36
|
+
* the same way `coerce` drops an unrecognised config value.
|
|
37
|
+
*/
|
|
38
|
+
export const GUIDANCE_TOPIC_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One topic is capped at the body limit. Guidance is read into agent context
|
|
42
|
+
* on every session, and a repository could otherwise feed it without bound.
|
|
43
|
+
*/
|
|
44
|
+
export const GUIDANCE_TOPIC_MAX_BYTES = 16_000
|
|
45
|
+
|
|
46
|
+
export function globalGuidanceDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
47
|
+
return path.join(globalConfigDir(env), 'guidance')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Walk up from cwd looking for `.notifai/guidance` (shared, committed). */
|
|
51
|
+
export function findProjectGuidanceDir(startDir: string): string | null {
|
|
52
|
+
let dir = path.resolve(startDir)
|
|
53
|
+
for (;;) {
|
|
54
|
+
const candidate = path.join(dir, '.notifai', 'guidance')
|
|
55
|
+
if (existsSync(candidate)) return candidate
|
|
56
|
+
const parent = path.dirname(dir)
|
|
57
|
+
if (parent === dir) return null
|
|
58
|
+
dir = parent
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Personal project guidance lives beside the personal project config file,
|
|
64
|
+
* under the user's config home rather than the repository:
|
|
65
|
+
* `$XDG_CONFIG_HOME/notifai/projects/<identity>/guidance`.
|
|
66
|
+
*/
|
|
67
|
+
export function personalProjectGuidanceDir(
|
|
68
|
+
cwd: string,
|
|
69
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
70
|
+
): string {
|
|
71
|
+
return path.join(globalConfigDir(env), 'projects', personalProjectIdentity(cwd), 'guidance')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function topicsInDir(dir: string): Map<string, string> {
|
|
75
|
+
const topics = new Map<string, string>()
|
|
76
|
+
if (!existsSync(dir)) return topics
|
|
77
|
+
let entries: string[]
|
|
78
|
+
try {
|
|
79
|
+
entries = readdirSync(dir)
|
|
80
|
+
} catch {
|
|
81
|
+
return topics
|
|
82
|
+
}
|
|
83
|
+
for (const entry of entries.sort()) {
|
|
84
|
+
if (!entry.endsWith('.md')) continue
|
|
85
|
+
const name = entry.slice(0, -'.md'.length)
|
|
86
|
+
if (!GUIDANCE_TOPIC_PATTERN.test(name)) continue
|
|
87
|
+
topics.set(name, path.join(dir, entry))
|
|
88
|
+
}
|
|
89
|
+
return topics
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readTopicFile(filePath: string): string | null {
|
|
93
|
+
let content: string
|
|
94
|
+
try {
|
|
95
|
+
content = readFileSync(filePath, 'utf8')
|
|
96
|
+
} catch {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
if (content.trim() === '') return null
|
|
100
|
+
if (content.length > GUIDANCE_TOPIC_MAX_BYTES) {
|
|
101
|
+
return `${content.slice(0, GUIDANCE_TOPIC_MAX_BYTES)}\n\n[Truncated: this guidance file exceeds ${GUIDANCE_TOPIC_MAX_BYTES} bytes.]\n`
|
|
102
|
+
}
|
|
103
|
+
return content
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every effective topic, shipped reading order first, additive user topics
|
|
108
|
+
* after it in name order. Each carries the layer that supplied it.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveGuidance(options: {
|
|
111
|
+
cwd?: string
|
|
112
|
+
env?: NodeJS.ProcessEnv
|
|
113
|
+
}): ResolvedGuidanceTopic[] {
|
|
114
|
+
const env = options.env ?? process.env
|
|
115
|
+
const cwd = options.cwd ?? process.cwd()
|
|
116
|
+
|
|
117
|
+
// Ascending precedence: a later layer overwrites an earlier one per topic.
|
|
118
|
+
const layers: { dir: string | null; label: 'global' | 'project' | 'project-local' }[] = [
|
|
119
|
+
{ dir: globalGuidanceDir(env), label: 'global' },
|
|
120
|
+
{ dir: findProjectGuidanceDir(cwd), label: 'project' },
|
|
121
|
+
{ dir: personalProjectGuidanceDir(cwd, env), label: 'project-local' },
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
const winners = new Map<string, { source: GuidanceSource; content: string }>()
|
|
125
|
+
for (const layer of layers) {
|
|
126
|
+
if (layer.dir === null) continue
|
|
127
|
+
for (const [name, filePath] of topicsInDir(layer.dir)) {
|
|
128
|
+
const content = readTopicFile(filePath)
|
|
129
|
+
if (content === null) continue
|
|
130
|
+
winners.set(name, { source: `${layer.label}:${filePath}`, content })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const resolved: ResolvedGuidanceTopic[] = SHIPPED_GUIDANCE.map((topic) => {
|
|
135
|
+
const override = winners.get(topic.name)
|
|
136
|
+
winners.delete(topic.name)
|
|
137
|
+
return override === undefined
|
|
138
|
+
? { name: topic.name, source: 'default', summary: topic.summary, content: topic.content }
|
|
139
|
+
: { name: topic.name, source: override.source, summary: topic.summary, content: override.content }
|
|
140
|
+
})
|
|
141
|
+
for (const name of [...winners.keys()].sort()) {
|
|
142
|
+
const extra = winners.get(name)!
|
|
143
|
+
resolved.push({
|
|
144
|
+
name,
|
|
145
|
+
source: extra.source,
|
|
146
|
+
summary: 'User-added guidance',
|
|
147
|
+
content: extra.content,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
return resolved
|
|
151
|
+
}
|
package/src/hooks.test.ts
CHANGED
|
@@ -157,7 +157,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
157
157
|
return {
|
|
158
158
|
beginPairing: notUsed,
|
|
159
159
|
pollPairing: notUsed,
|
|
160
|
-
//
|
|
160
|
+
// Current reply-capable Companion fixtures.
|
|
161
161
|
listDevices: async () => ({
|
|
162
162
|
devices: [
|
|
163
163
|
{
|
|
@@ -555,6 +555,38 @@ describe('pushing a registered question', () => {
|
|
|
555
555
|
expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBe('attention')
|
|
556
556
|
})
|
|
557
557
|
|
|
558
|
+
it('routes a registered question to an answer-capable Android installation', async () => {
|
|
559
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
560
|
+
const client = {
|
|
561
|
+
...fakeClient(h.recorder, [reply({ text: 'Yes' })]),
|
|
562
|
+
listDevices: async () => ({
|
|
563
|
+
devices: [
|
|
564
|
+
{
|
|
565
|
+
device_id: 'dev_android',
|
|
566
|
+
display_name: 'Pixel',
|
|
567
|
+
platform: 'android' as const,
|
|
568
|
+
permission_status: 'authorized',
|
|
569
|
+
registration_healthy: true,
|
|
570
|
+
capabilities: ['answer'],
|
|
571
|
+
derived_status: 'working',
|
|
572
|
+
last_seen_at: null,
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
}),
|
|
576
|
+
} as ApiClient
|
|
577
|
+
const deps = { ...h.deps, clientFactory: () => client }
|
|
578
|
+
writeSessionState('android-answer', h.env, { last_prompt_at: AWAY })
|
|
579
|
+
registerQuestion('android-answer', h.env, { question: 'Ship it?' }, NOW)
|
|
580
|
+
|
|
581
|
+
await hookRunCommand(deps, 'stop', stdin({ session_id: 'android-answer' }))
|
|
582
|
+
|
|
583
|
+
expect(h.recorder.submitted[0]?.draft.targets).toEqual({
|
|
584
|
+
mode: 'selected',
|
|
585
|
+
device_ids: ['dev_android'],
|
|
586
|
+
})
|
|
587
|
+
expect(h.recorder.submitted[0]?.draft.platform?.android?.sound).toBe('attention')
|
|
588
|
+
})
|
|
589
|
+
|
|
558
590
|
it('keeps sparse alt text paired with its original media item', async () => {
|
|
559
591
|
const h = harness([reply({ text: 'Yes' })])
|
|
560
592
|
writeSessionState('media-order', h.env, { last_prompt_at: AWAY })
|
|
@@ -2323,9 +2355,9 @@ describe('ask registration', () => {
|
|
|
2323
2355
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'form1' }))
|
|
2324
2356
|
const draft = h.recorder.submitted[0]?.draft
|
|
2325
2357
|
expect(draft?.presentation.title).toBe('Deploy where?')
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
)
|
|
2358
|
+
// The questions travel structured and the first one is the title; the
|
|
2359
|
+
// body carries only the context so nothing renders twice.
|
|
2360
|
+
expect(draft?.presentation.body).toBe('## Context\nThe long story.')
|
|
2329
2361
|
expect(draft?.reply?.questions).toEqual([
|
|
2330
2362
|
{
|
|
2331
2363
|
id: 'deploy-where',
|
|
@@ -2818,7 +2850,7 @@ describe('session-end hook', () => {
|
|
|
2818
2850
|
'the real Codex hook did not reach contended diagnostics before its deadline',
|
|
2819
2851
|
)
|
|
2820
2852
|
|
|
2821
|
-
// Reaching the second bakery ticket proves
|
|
2853
|
+
// Reaching the second bakery ticket proves program.ts preAction did not write
|
|
2822
2854
|
// first. Every durable cleanup must already be visible while diagnostics
|
|
2823
2855
|
// are still blocked behind the live holder.
|
|
2824
2856
|
expect(existsSync(stateFile)).toBe(false)
|
package/src/hooks.ts
CHANGED
|
@@ -651,8 +651,9 @@ function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
|
651
651
|
/**
|
|
652
652
|
* A question is stored so a later hook can push it, and it reaches us from a
|
|
653
653
|
* shell command, so its size is whatever the agent typed. The push itself is
|
|
654
|
-
* bounded by the 4096-byte
|
|
655
|
-
* meantime, and keeps one runaway agent from writing
|
|
654
|
+
* bounded by the current 4096-byte push-provider envelopes; this bounds what
|
|
655
|
+
* sits on disk in the meantime, and keeps one runaway agent from writing
|
|
656
|
+
* megabytes per session.
|
|
656
657
|
*/
|
|
657
658
|
const MAX_STORED_QUESTION_CHARS = 2000
|
|
658
659
|
|
|
@@ -1089,17 +1090,16 @@ function permanentReplyFailureNote(failures: Map<string, string>): string | null
|
|
|
1089
1090
|
return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`
|
|
1090
1091
|
}
|
|
1091
1092
|
|
|
1092
|
-
/**
|
|
1093
|
-
* Healthy companion devices that implement replies. Both the iOS app and the
|
|
1094
|
-
* macOS app register reply categories and submit answers directly.
|
|
1095
|
-
*/
|
|
1093
|
+
/** Healthy Companion devices that advertise the current answer job. */
|
|
1096
1094
|
async function answerableDevices(ctx: HookContext): Promise<string[]> {
|
|
1097
1095
|
const configured = ctx.config.devices.value
|
|
1098
1096
|
const { devices } = await ctx.client.listDevices()
|
|
1099
1097
|
return devices
|
|
1100
1098
|
.filter(
|
|
1101
1099
|
(device) =>
|
|
1102
|
-
(device.platform === 'ios' ||
|
|
1100
|
+
(device.platform === 'ios' ||
|
|
1101
|
+
device.platform === 'macos' ||
|
|
1102
|
+
device.platform === 'android') &&
|
|
1103
1103
|
device.registration_healthy &&
|
|
1104
1104
|
device.capabilities?.includes('answer') === true &&
|
|
1105
1105
|
device.derived_status !== 'must_update',
|
package/src/interactive.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* The menu leads with whatever is actually in the way. Someone who is not
|
|
19
19
|
* signed in is offered sign-in first; someone with no device that can receive
|
|
20
20
|
* is offered a device; everyone else is offered the test notification, because
|
|
21
|
-
* seeing the thing arrive on your
|
|
21
|
+
* seeing the thing arrive on your device is what makes the product real.
|
|
22
22
|
*
|
|
23
23
|
* Detail is earned, not given. A settings group shows fourteen one-line
|
|
24
24
|
* summaries, and the paragraph explaining a key appears once the reader has
|
|
@@ -365,7 +365,11 @@ function menuFor(readiness: Readiness): { value: Screen; label: string; hint?: s
|
|
|
365
365
|
if (!signedIn) {
|
|
366
366
|
options.push({ value: 'account', label: 'Sign in', hint: 'approve this machine in your browser' })
|
|
367
367
|
} else if (!hasDevice) {
|
|
368
|
-
options.push({
|
|
368
|
+
options.push({
|
|
369
|
+
value: 'devices',
|
|
370
|
+
label: 'Add a device',
|
|
371
|
+
hint: 'install a supported Companion App',
|
|
372
|
+
})
|
|
369
373
|
} else {
|
|
370
374
|
options.push({
|
|
371
375
|
value: 'test',
|
|
@@ -414,8 +418,8 @@ async function testNotificationScreen(deps: CommandDeps): Promise<void> {
|
|
|
414
418
|
|
|
415
419
|
const body = await clack.text({
|
|
416
420
|
message: 'Body',
|
|
417
|
-
placeholder: 'If you can read this on your
|
|
418
|
-
defaultValue: 'If you can read this on your
|
|
421
|
+
placeholder: 'If you can read this on your device, it works.',
|
|
422
|
+
defaultValue: 'If you can read this on your device, it works.',
|
|
419
423
|
})
|
|
420
424
|
if (cancelled(body)) return
|
|
421
425
|
|
|
@@ -457,8 +461,8 @@ async function devicesScreen(deps: CommandDeps): Promise<void> {
|
|
|
457
461
|
if (devices.length === 0) {
|
|
458
462
|
clack.note(
|
|
459
463
|
'No devices are registered yet.\n\n' +
|
|
460
|
-
'Install
|
|
461
|
-
'
|
|
464
|
+
'Install a supported Notifai Companion App, sign in with the same\n' +
|
|
465
|
+
'Account, and allow notifications when it asks.',
|
|
462
466
|
'No devices',
|
|
463
467
|
)
|
|
464
468
|
return
|
|
@@ -733,8 +737,9 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
|
|
|
733
737
|
const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
|
|
734
738
|
clack.note(
|
|
735
739
|
'When an agent registers a question, it appears in your terminal first.\n' +
|
|
736
|
-
'After the grace below, it goes to your
|
|
737
|
-
'with
|
|
740
|
+
'After the grace below, it goes to your Companion devices as a\n' +
|
|
741
|
+
'notification with a supported native or in-app answer surface, and\n' +
|
|
742
|
+
'your answer comes back to the agent —\n' +
|
|
738
743
|
'whether or not you are still at this machine.\n\n' +
|
|
739
744
|
[
|
|
740
745
|
row('Wired to', wiringSummary(hooks)),
|