@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/commands-devices.d.ts +2 -1
  3. package/dist/commands-devices.js +20 -8
  4. package/dist/commands-devices.js.map +1 -1
  5. package/dist/commands-doctor.js +14 -12
  6. package/dist/commands-doctor.js.map +1 -1
  7. package/dist/commands-guidance.d.ts +23 -0
  8. package/dist/commands-guidance.js +128 -0
  9. package/dist/commands-guidance.js.map +1 -0
  10. package/dist/commands-hooks.d.ts +7 -1
  11. package/dist/commands-hooks.js +3 -6
  12. package/dist/commands-hooks.js.map +1 -1
  13. package/dist/commands-init.js +25 -22
  14. package/dist/commands-init.js.map +1 -1
  15. package/dist/commands-send-support.js +5 -3
  16. package/dist/commands-send-support.js.map +1 -1
  17. package/dist/commands-send.d.ts +0 -1
  18. package/dist/commands-send.js +45 -28
  19. package/dist/commands-send.js.map +1 -1
  20. package/dist/commands.d.ts +1 -0
  21. package/dist/commands.js +1 -0
  22. package/dist/commands.js.map +1 -1
  23. package/dist/config-schema.js +6 -15
  24. package/dist/config-schema.js.map +1 -1
  25. package/dist/config.d.ts +1 -3
  26. package/dist/config.js +0 -3
  27. package/dist/config.js.map +1 -1
  28. package/dist/guidance-content.d.ts +30 -0
  29. package/dist/guidance-content.js +175 -0
  30. package/dist/guidance-content.js.map +1 -0
  31. package/dist/guidance.d.ts +48 -0
  32. package/dist/guidance.js +118 -0
  33. package/dist/guidance.js.map +1 -0
  34. package/dist/hooks.js +7 -7
  35. package/dist/hooks.js.map +1 -1
  36. package/dist/interactive.d.ts +1 -1
  37. package/dist/interactive.js +13 -8
  38. package/dist/interactive.js.map +1 -1
  39. package/dist/main.js +3 -474
  40. package/dist/main.js.map +1 -1
  41. package/dist/program.d.ts +49 -0
  42. package/dist/program.js +571 -0
  43. package/dist/program.js.map +1 -0
  44. package/dist/send.d.ts +4 -9
  45. package/dist/send.js +48 -28
  46. package/dist/send.js.map +1 -1
  47. package/dist/session-labels.d.ts +9 -3
  48. package/dist/session-labels.js +38 -7
  49. package/dist/session-labels.js.map +1 -1
  50. package/package.json +5 -3
  51. package/src/commands-devices.ts +28 -9
  52. package/src/commands-doctor.ts +17 -12
  53. package/src/commands-guidance.ts +170 -0
  54. package/src/commands-hooks.ts +10 -8
  55. package/src/commands-init.ts +31 -22
  56. package/src/commands-send-support.ts +5 -3
  57. package/src/commands-send.ts +50 -28
  58. package/src/commands.test.ts +374 -63
  59. package/src/commands.ts +5 -0
  60. package/src/config-schema.ts +6 -17
  61. package/src/config.test.ts +56 -1
  62. package/src/config.ts +0 -5
  63. package/src/guidance-content.ts +190 -0
  64. package/src/guidance.test.ts +268 -0
  65. package/src/guidance.ts +151 -0
  66. package/src/hooks.test.ts +37 -5
  67. package/src/hooks.ts +7 -7
  68. package/src/interactive.ts +13 -8
  69. package/src/main.ts +4 -600
  70. package/src/program.test.ts +202 -0
  71. package/src/program.ts +723 -0
  72. package/src/send.ts +53 -32
  73. package/src/session-labels.test.ts +120 -3
  74. package/src/session-labels.ts +38 -7
  75. package/src/skill.test.ts +37 -12
@@ -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
- // Both current companion apps register reply categories.
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
- expect(draft?.presentation.body).toBe(
2327
- '1. Deploy where?\n2. Anything to watch?\n\n## Context\nThe long story.',
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 main.ts preAction did not write
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 APNs envelope; this bounds what sits on disk in the
655
- * meantime, and keeps one runaway agent from writing megabytes per session.
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' || device.platform === 'macos') &&
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',
@@ -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 phone is what makes the product real.
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({ value: 'devices', label: 'Add a device', hint: 'install the app on your iPhone' })
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 phone, it works.',
418
- defaultValue: 'If you can read this on your phone, it works.',
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 the Notifai Companion App on your iPhone, sign in with the\n' +
461
- 'same Account, and allow notifications when it asks.',
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 iPhone as a notification\n' +
737
- 'with real answer buttons, and your answer comes back to the agent —\n' +
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)),