@raidiant/notifai 0.5.0 → 1.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 (74) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +11 -4
  3. package/dist/claude-wake.d.ts +101 -0
  4. package/dist/claude-wake.js +397 -0
  5. package/dist/claude-wake.js.map +1 -0
  6. package/dist/codex-wake.d.ts +84 -0
  7. package/dist/codex-wake.js +238 -0
  8. package/dist/codex-wake.js.map +1 -0
  9. package/dist/commands.d.ts +31 -11
  10. package/dist/commands.js +679 -232
  11. package/dist/commands.js.map +1 -1
  12. package/dist/config-schema.d.ts +3 -3
  13. package/dist/config-schema.js +11 -49
  14. package/dist/config-schema.js.map +1 -1
  15. package/dist/config.d.ts +23 -45
  16. package/dist/config.js +61 -39
  17. package/dist/config.js.map +1 -1
  18. package/dist/harnesses.d.ts +4 -13
  19. package/dist/harnesses.js +12 -17
  20. package/dist/harnesses.js.map +1 -1
  21. package/dist/hook-adapter.d.ts +10 -1
  22. package/dist/hook-adapter.js +79 -10
  23. package/dist/hook-adapter.js.map +1 -1
  24. package/dist/hooks.d.ts +119 -65
  25. package/dist/hooks.js +330 -285
  26. package/dist/hooks.js.map +1 -1
  27. package/dist/install-hooks.d.ts +76 -24
  28. package/dist/install-hooks.js +301 -125
  29. package/dist/install-hooks.js.map +1 -1
  30. package/dist/interactive.d.ts +32 -1
  31. package/dist/interactive.js +165 -51
  32. package/dist/interactive.js.map +1 -1
  33. package/dist/main.js +14 -21
  34. package/dist/main.js.map +1 -1
  35. package/dist/native-skills.d.ts +1 -1
  36. package/dist/native-skills.js +23 -58
  37. package/dist/native-skills.js.map +1 -1
  38. package/dist/pack-feedback-slice.d.ts +35 -0
  39. package/dist/pack-feedback-slice.js +126 -0
  40. package/dist/pack-feedback-slice.js.map +1 -0
  41. package/dist/readiness.d.ts +20 -0
  42. package/dist/readiness.js +24 -0
  43. package/dist/readiness.js.map +1 -1
  44. package/dist/release.d.ts +31 -0
  45. package/dist/release.js +52 -0
  46. package/dist/release.js.map +1 -0
  47. package/dist/ui/theme.d.ts +1 -1
  48. package/package.json +11 -10
  49. package/src/commands.test.ts +386 -36
  50. package/src/commands.ts +473 -182
  51. package/src/config-schema.ts +2 -17
  52. package/src/config.test.ts +29 -4
  53. package/src/config.ts +67 -11
  54. package/src/harnesses.ts +7 -0
  55. package/src/hook-adapter.test.ts +43 -1
  56. package/src/hook-adapter.ts +83 -11
  57. package/src/hooks.test.ts +4 -5
  58. package/src/install-hooks.test.ts +154 -2
  59. package/src/install-hooks.ts +231 -44
  60. package/src/interactive.test.ts +338 -0
  61. package/src/interactive.ts +221 -49
  62. package/src/main.ts +14 -20
  63. package/src/native-skills.test.ts +75 -0
  64. package/src/native-skills.ts +31 -75
  65. package/src/pack-feedback-slice.test.ts +212 -0
  66. package/src/pack-feedback-slice.ts +187 -0
  67. package/src/readiness.test.ts +25 -0
  68. package/src/readiness.ts +38 -0
  69. package/src/release.ts +51 -0
  70. package/src/skill.test.ts +43 -0
  71. package/src/ui/config-view.test.ts +5 -4
  72. package/dist/idle.d.ts +0 -16
  73. package/dist/idle.js +0 -44
  74. package/dist/idle.js.map +0 -1
@@ -33,24 +33,16 @@ export interface SkillsAddOptions {
33
33
  export interface NativeSkills {
34
34
  /** Launch the native interactive `npx skills add` flow. */
35
35
  add(options: SkillsAddOptions): Promise<number>
36
- /** Ask the native installer for its managed install inventory. */
36
+ /** Read installer-managed inventory from lock files. Does not spawn npx. */
37
37
  list(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): Promise<SkillsListResult>
38
38
  }
39
39
 
40
- interface JsonSkill {
41
- name?: unknown
42
- path?: unknown
43
- scope?: unknown
44
- source?: unknown
45
- sourceType?: unknown
46
- sourceUrl?: unknown
47
- }
48
-
49
40
  interface LockEntry {
50
41
  source?: unknown
51
42
  sourceType?: unknown
52
43
  sourceUrl?: unknown
53
44
  ref?: unknown
45
+ skillPath?: unknown
54
46
  }
55
47
 
56
48
  interface LockFile {
@@ -77,78 +69,47 @@ function readLock(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): LockF
77
69
  }
78
70
  }
79
71
 
80
- function lockEntryFor(lock: LockFile, name: string): LockEntry | undefined {
81
- const direct = lock.skills?.[name]
82
- if (direct) return direct
83
- const normalized = name.toLowerCase().replace(/[\s_]+/g, '-')
84
- const key = Object.keys(lock.skills ?? {}).find(
85
- (candidate) => candidate.toLowerCase().replace(/[\s_]+/g, '-') === normalized,
86
- )
87
- return key === undefined ? undefined : lock.skills?.[key]
88
- }
89
-
90
- function jsonArray(stdout: string): unknown[] | null {
91
- const start = stdout.indexOf('[')
92
- const end = stdout.lastIndexOf(']')
93
- if (start < 0 || end < start) return null
94
- try {
95
- const parsed: unknown = JSON.parse(stdout.slice(start, end + 1))
96
- return Array.isArray(parsed) ? parsed : null
97
- } catch {
98
- return null
99
- }
72
+ function conventionalSkillPath(
73
+ scope: SkillScope,
74
+ name: string,
75
+ cwd: string,
76
+ env: NodeJS.ProcessEnv,
77
+ ): string {
78
+ if (scope === 'project') return path.join(cwd, '.agents', 'skills', name)
79
+ const home = env['HOME'] ?? env['USERPROFILE'] ?? os.homedir()
80
+ return path.join(home, '.agents', 'skills', name)
100
81
  }
101
82
 
102
- function managedSkills(stdout: string, scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): NativeSkill[] | null {
103
- const entries = jsonArray(stdout)
104
- if (entries === null) return null
83
+ function skillsFromLock(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): NativeSkill[] {
105
84
  const lock = readLock(scope, cwd, env)
106
- return entries.flatMap((entry): NativeSkill[] => {
85
+ return Object.entries(lock.skills ?? {}).flatMap(([name, entry]): NativeSkill[] => {
107
86
  if (entry === null || typeof entry !== 'object') return []
108
- const value = entry as JsonSkill
109
- if (
110
- typeof value.name !== 'string' ||
111
- typeof value.path !== 'string' ||
112
- value.scope !== scope
113
- ) {
114
- return []
115
- }
116
- const lockEntry = lockEntryFor(lock, value.name)
117
87
  return [
118
88
  {
119
- name: value.name,
89
+ name,
120
90
  scope,
121
- path: value.path,
122
- source: typeof value.source === 'string' ? value.source : null,
123
- sourceType: typeof value.sourceType === 'string' ? value.sourceType : null,
124
- sourceUrl: typeof value.sourceUrl === 'string' ? value.sourceUrl : null,
125
- ref: typeof lockEntry?.ref === 'string' ? lockEntry.ref : null,
91
+ path:
92
+ typeof entry.skillPath === 'string' && entry.skillPath !== ''
93
+ ? entry.skillPath
94
+ : conventionalSkillPath(scope, name, cwd, env),
95
+ source: typeof entry.source === 'string' ? entry.source : null,
96
+ sourceType: typeof entry.sourceType === 'string' ? entry.sourceType : null,
97
+ sourceUrl: typeof entry.sourceUrl === 'string' ? entry.sourceUrl : null,
98
+ ref: typeof entry.ref === 'string' ? entry.ref : null,
126
99
  },
127
100
  ]
128
101
  })
129
102
  }
130
103
 
131
- function run(
132
- args: string[],
133
- options: { cwd: string; env: NodeJS.ProcessEnv; stdio: 'inherit' | 'capture' },
134
- ): Promise<{ code: number; stdout: string }> {
104
+ function run(args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }): Promise<number> {
135
105
  return new Promise((resolve) => {
136
106
  const child = spawn('npx', args, {
137
107
  cwd: options.cwd,
138
108
  env: options.env,
139
- stdio: options.stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'ignore'],
109
+ stdio: 'inherit',
140
110
  })
141
- if (options.stdio === 'capture') {
142
- let stdout = ''
143
- child.stdout?.on('data', (chunk: Buffer | string) => {
144
- stdout += chunk.toString()
145
- })
146
- child.on('error', () => resolve({ code: 1, stdout }))
147
- child.on('exit', (code) => resolve({ code: code ?? 1, stdout }))
148
- return
149
- }
150
- child.on('error', () => resolve({ code: 1, stdout: '' }))
151
- child.on('exit', (code) => resolve({ code: code ?? 1, stdout: '' }))
111
+ child.on('error', () => resolve(1))
112
+ child.on('exit', (code) => resolve(code ?? 1))
152
113
  })
153
114
  }
154
115
 
@@ -160,18 +121,13 @@ export const nativeSkills: NativeSkills = {
160
121
  // An explicit scope is the unattended contract. Native `--yes` keeps all
161
122
  // remaining installer prompts non-interactive after the scope is chosen.
162
123
  if (options.scope !== undefined) args.push('--yes')
163
- return (await run(args, { cwd: options.cwd, env: options.env, stdio: 'inherit' })).code
124
+ return run(args, { cwd: options.cwd, env: options.env })
164
125
  },
165
126
 
166
127
  async list(scope, cwd, env) {
167
- const result = await run(
168
- ['-y', 'skills', 'list', '--json', ...(scope === 'global' ? ['--global'] : [])],
169
- { cwd, env, stdio: 'capture' },
170
- )
171
- if (result.code !== 0) return { skills: [], error: `npx skills list exited with code ${result.code}` }
172
- const skills = managedSkills(result.stdout, scope, cwd, env)
173
- return skills === null
174
- ? { skills: [], error: 'npx skills list returned invalid JSON' }
175
- : { skills }
128
+ // Presence is already on disk. `npx skills list` takes seconds and cannot
129
+ // change whether the notifai skill is installed — the lock file is what
130
+ // the installer itself consults.
131
+ return { skills: skillsFromLock(scope, cwd, env) }
176
132
  },
177
133
  }
@@ -0,0 +1,212 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { gunzipSync } from 'node:zlib'
6
+ import { describe, expect, it } from 'vitest'
7
+ import { LOG_SCHEMA_VERSION, activeLogPath, createLogger, type LogRecord } from './logging.js'
8
+ import { encodeFeedbackSlice, packFeedbackSlice } from './pack-feedback-slice.js'
9
+
10
+ function sandbox(): NodeJS.ProcessEnv {
11
+ return { XDG_STATE_HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-feedback-')) }
12
+ }
13
+
14
+ function writeLog(env: NodeJS.ProcessEnv, records: LogRecord[]): void {
15
+ const file = activeLogPath(env)
16
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
17
+ writeFileSync(file, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, {
18
+ mode: 0o600,
19
+ })
20
+ }
21
+
22
+ function record(overrides: Partial<LogRecord> & Pick<LogRecord, 'ts'>): LogRecord {
23
+ return {
24
+ v: 1,
25
+ level: 'info',
26
+ event: 'cli.end',
27
+ run: 'r_1',
28
+ cmd: 'send',
29
+ pid: 1,
30
+ ...overrides,
31
+ }
32
+ }
33
+
34
+ describe('packFeedbackSlice', () => {
35
+ it('returns an empty result with no gzip when there is no log', () => {
36
+ const result = packFeedbackSlice(sandbox())
37
+
38
+ expect(result.record_count).toBe(0)
39
+ expect(result.records).toEqual([])
40
+ expect(result.jsonl).toBe('')
41
+ expect(result.gzip).toBeUndefined()
42
+ expect(result.uncompressed_bytes).toBe(0)
43
+ expect(result.compressed_bytes).toBe(0)
44
+ expect(result.truncated).toBe(false)
45
+ expect(result.consentSummary.toLowerCase()).toMatch(/credential/)
46
+ expect(result.consentSummary.toLowerCase()).toMatch(/title/)
47
+ expect(result.consentSummary.toLowerCase()).toMatch(/answer/)
48
+ })
49
+
50
+ it('packs records inside the default 24-hour window and drops older ones', () => {
51
+ const env = sandbox()
52
+ const now = Date.parse('2026-08-13T12:00:00.000Z')
53
+ writeLog(env, [
54
+ record({ ts: '2026-08-12T11:00:00.000Z', data: { which: 'too-old' } }),
55
+ record({ ts: '2026-08-12T13:00:00.000Z', data: { which: 'kept' } }),
56
+ ])
57
+
58
+ const result = packFeedbackSlice(env, { now })
59
+
60
+ expect(result.record_count).toBe(1)
61
+ expect(result.records[0]?.data).toMatchObject({ which: 'kept' })
62
+ expect(result.since).toBe(Date.parse('2026-08-12T12:00:00.000Z'))
63
+ expect(result.until).toBe(now)
64
+ expect(result.jsonl).toContain('kept')
65
+ expect(result.jsonl).not.toContain('too-old')
66
+ expect(result.gzip).toBeDefined()
67
+ expect(result.uncompressed_bytes).toBe(Buffer.byteLength(result.jsonl))
68
+ expect(result.compressed_bytes).toBe(result.gzip!.length)
69
+ expect(result.truncated).toBe(false)
70
+ })
71
+
72
+ it('gives consent copy that names redacted credentials, titles, answers, window, and size', () => {
73
+ const env = sandbox()
74
+ const now = Date.parse('2026-08-13T12:00:00.000Z')
75
+ writeLog(env, [record({ ts: '2026-08-13T11:00:00.000Z' })])
76
+
77
+ const result = packFeedbackSlice(env, { now })
78
+ const copy = result.consentSummary.toLowerCase()
79
+
80
+ expect(copy).toMatch(/credential/)
81
+ expect(copy).toMatch(/already removed|already redacted|redacted/)
82
+ expect(copy).toMatch(/title/)
83
+ expect(copy).toMatch(/answer/)
84
+ expect(result.consentSummary).toContain('2026-08-12T12:00:00.000Z')
85
+ expect(result.consentSummary).toContain('2026-08-13T12:00:00.000Z')
86
+ expect(result.consentSummary).toContain(String(result.uncompressed_bytes))
87
+ expect(result.consentSummary).toContain(String(result.compressed_bytes))
88
+ expect(result.consentSummary).toContain(String(result.record_count))
89
+ })
90
+
91
+ it('applies extra log query filters on top of the window', () => {
92
+ const env = sandbox()
93
+ const now = Date.parse('2026-08-13T12:00:00.000Z')
94
+ writeLog(env, [
95
+ record({ ts: '2026-08-13T11:00:00.000Z', event: 'cli.end', data: { which: 'end' } }),
96
+ record({ ts: '2026-08-13T11:01:00.000Z', event: 'cli.error', data: { which: 'error' } }),
97
+ ])
98
+
99
+ const result = packFeedbackSlice(env, { now, event: ['cli.error'] })
100
+
101
+ expect(result.record_count).toBe(1)
102
+ expect(result.records[0]?.event).toBe('cli.error')
103
+ expect(result.records[0]?.data).toMatchObject({ which: 'error' })
104
+ })
105
+
106
+ it('drops oldest records until the uncompressed JSONL cap holds', () => {
107
+ const env = sandbox()
108
+ const now = Date.parse('2026-08-13T12:00:00.000Z')
109
+ const start = Date.parse('2026-08-13T10:00:00.000Z')
110
+ const pad = 'x'.repeat(2000)
111
+ writeLog(
112
+ env,
113
+ Array.from({ length: 200 }, (_, sequence) =>
114
+ record({
115
+ ts: new Date(start + sequence * 1000).toISOString(),
116
+ data: { sequence, pad },
117
+ }),
118
+ ),
119
+ )
120
+
121
+ const result = packFeedbackSlice(env, { now })
122
+ const sequences = result.records.map((entry) => entry.data?.['sequence'])
123
+
124
+ expect(result.uncompressed_bytes).toBeLessThanOrEqual(256 * 1024)
125
+ expect(result.compressed_bytes).toBeLessThanOrEqual(128 * 1024)
126
+ expect(result.truncated).toBe(true)
127
+ expect(result.record_count).toBeGreaterThan(0)
128
+ expect(sequences[0]).toBeGreaterThan(0)
129
+ expect(sequences.at(-1)).toBe(199)
130
+ })
131
+
132
+ it('drops oldest records until the gzip cap holds', () => {
133
+ const env = sandbox()
134
+ const now = Date.parse('2026-08-13T12:00:00.000Z')
135
+ const start = Date.parse('2026-08-13T10:00:00.000Z')
136
+ const seeded = Array.from({ length: 80 }, (_, sequence) =>
137
+ record({
138
+ ts: new Date(start + sequence * 1000).toISOString(),
139
+ data: { sequence, pad: randomBytes(1800).toString('base64') },
140
+ }),
141
+ )
142
+ writeLog(env, seeded)
143
+
144
+ const result = packFeedbackSlice(env, { now })
145
+ const sequences = result.records.map((entry) => entry.data?.['sequence'])
146
+
147
+ expect(result.uncompressed_bytes).toBeLessThanOrEqual(256 * 1024)
148
+ expect(result.compressed_bytes).toBeLessThanOrEqual(128 * 1024)
149
+ expect(result.truncated).toBe(true)
150
+ expect(result.record_count).toBeGreaterThan(0)
151
+ expect(result.record_count).toBeLessThan(seeded.length)
152
+ expect(sequences[0]).toBeGreaterThan(0)
153
+ expect(sequences.at(-1)).toBe(79)
154
+ })
155
+
156
+ it('encodes the gzip slice as a gzip+base64 wire payload', () => {
157
+ const env = sandbox()
158
+ const now = Date.parse('2026-08-13T12:00:00.000Z')
159
+ writeLog(env, [record({ ts: '2026-08-13T11:00:00.000Z', data: { which: 'wire' } })])
160
+
161
+ const packed = packFeedbackSlice(env, { now })
162
+ const wire = encodeFeedbackSlice(packed)
163
+
164
+ expect(wire).toEqual({
165
+ encoding: 'gzip+base64',
166
+ bytes: packed.gzip!.toString('base64'),
167
+ uncompressed_bytes: packed.uncompressed_bytes,
168
+ compressed_bytes: packed.compressed_bytes,
169
+ record_count: packed.record_count,
170
+ truncated: false,
171
+ since: '2026-08-12T12:00:00.000Z',
172
+ until: '2026-08-13T12:00:00.000Z',
173
+ schema_version: packed.schema_version,
174
+ })
175
+ expect(gunzipSync(Buffer.from(wire!.bytes, 'base64')).toString('utf8')).toBe(packed.jsonl)
176
+ expect(encodeFeedbackSlice(packFeedbackSlice(sandbox()))).toBeUndefined()
177
+ })
178
+
179
+ it('honours an explicit since instead of the 24-hour default', () => {
180
+ const env = sandbox()
181
+ const now = Date.parse('2026-08-13T12:00:00.000Z')
182
+ writeLog(env, [
183
+ record({ ts: '2026-08-10T12:00:00.000Z', data: { which: 'old' } }),
184
+ record({ ts: '2026-08-13T11:00:00.000Z', data: { which: 'new' } }),
185
+ ])
186
+
187
+ const result = packFeedbackSlice(env, {
188
+ now,
189
+ since: Date.parse('2026-08-10T00:00:00.000Z'),
190
+ })
191
+
192
+ expect(result.records.map((entry) => entry.data?.['which'])).toEqual(['old', 'new'])
193
+ expect(result.since).toBe(Date.parse('2026-08-10T00:00:00.000Z'))
194
+ })
195
+
196
+ it('packs records written by the logger and skips a damaged line', () => {
197
+ const env = sandbox()
198
+ const logger = createLogger({ env, now: () => Date.parse('2026-08-13T11:30:00.000Z') })
199
+ logger.info('cli.end', { which: 'first' })
200
+ appendFileSync(activeLogPath(env), 'not json at all\n')
201
+ createLogger({ env, now: () => Date.parse('2026-08-13T11:31:00.000Z') }).info('cli.end', {
202
+ which: 'second',
203
+ })
204
+
205
+ const result = packFeedbackSlice(env, { now: Date.parse('2026-08-13T12:00:00.000Z') })
206
+
207
+ expect(result.schema_version).toBe(LOG_SCHEMA_VERSION)
208
+ expect(result.record_count).toBe(2)
209
+ expect(result.records.map((entry) => entry.data?.['which'])).toEqual(['first', 'second'])
210
+ expect(result.truncated).toBe(false)
211
+ })
212
+ })
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Consented, bounded slice of the local log for a later feedback send.
3
+ *
4
+ * This module packs. It does not upload. A caller that wants a report on
5
+ * the wire asks here for the bytes and the consent copy, then decides
6
+ * whether to send them.
7
+ */
8
+ import { gzipSync } from 'node:zlib'
9
+ import {
10
+ LOG_SCHEMA_VERSION,
11
+ readLogRecords,
12
+ type LogQuery,
13
+ type LogRecord,
14
+ } from './logging.js'
15
+
16
+ export const FEEDBACK_SLICE_UNCOMPRESSED_CAP = 256 * 1024
17
+ export const FEEDBACK_SLICE_COMPRESSED_CAP = 128 * 1024
18
+ export const FEEDBACK_SLICE_DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000
19
+
20
+ /** High enough that on-disk rotation, not this number, bounds a full-window read. */
21
+ const FULL_WINDOW_READ_LIMIT = 1_000_000
22
+
23
+ export interface PackFeedbackSliceOptions extends LogQuery {
24
+ /** Epoch ms. Defaults to now. Tests freeze the window with this. */
25
+ now?: number
26
+ }
27
+
28
+ export interface PackedFeedbackSlice {
29
+ records: LogRecord[]
30
+ jsonl: string
31
+ gzip?: Buffer
32
+ uncompressed_bytes: number
33
+ compressed_bytes: number
34
+ record_count: number
35
+ truncated: boolean
36
+ since: number
37
+ until: number
38
+ schema_version: number
39
+ consentSummary: string
40
+ }
41
+
42
+ export interface FeedbackSliceWire {
43
+ encoding: 'gzip+base64'
44
+ bytes: string
45
+ uncompressed_bytes: number
46
+ compressed_bytes: number
47
+ record_count: number
48
+ truncated: boolean
49
+ since: string
50
+ until: string
51
+ schema_version: number
52
+ }
53
+
54
+ /** Wire form. Empty slices have nothing to encode. */
55
+ export function encodeFeedbackSlice(slice: PackedFeedbackSlice): FeedbackSliceWire | undefined {
56
+ if (slice.gzip === undefined) return undefined
57
+ return {
58
+ encoding: 'gzip+base64',
59
+ bytes: slice.gzip.toString('base64'),
60
+ uncompressed_bytes: slice.uncompressed_bytes,
61
+ compressed_bytes: slice.compressed_bytes,
62
+ record_count: slice.record_count,
63
+ truncated: slice.truncated,
64
+ since: new Date(slice.since).toISOString(),
65
+ until: new Date(slice.until).toISOString(),
66
+ schema_version: slice.schema_version,
67
+ }
68
+ }
69
+
70
+ export function packFeedbackSlice(
71
+ env: NodeJS.ProcessEnv = process.env,
72
+ options: PackFeedbackSliceOptions = {},
73
+ ): PackedFeedbackSlice {
74
+ const { now, since: sinceOption, limit, ...filters } = options
75
+ const until = now ?? Date.now()
76
+ const since = sinceOption ?? until - FEEDBACK_SLICE_DEFAULT_WINDOW_MS
77
+ const { records } = readLogRecords(env, {
78
+ ...filters,
79
+ since,
80
+ limit: limit ?? FULL_WINDOW_READ_LIMIT,
81
+ })
82
+
83
+ if (records.length === 0) {
84
+ return emptySlice(since, until)
85
+ }
86
+
87
+ const packed = fitToCaps(records)
88
+ if (packed === null) {
89
+ return emptySlice(since, until)
90
+ }
91
+
92
+ return {
93
+ records: packed.records,
94
+ jsonl: packed.jsonl,
95
+ gzip: packed.gzip,
96
+ uncompressed_bytes: packed.uncompressedBytes,
97
+ compressed_bytes: packed.gzip.length,
98
+ record_count: packed.records.length,
99
+ truncated: packed.truncated,
100
+ since,
101
+ until,
102
+ schema_version: LOG_SCHEMA_VERSION,
103
+ consentSummary: consentSummary({
104
+ since,
105
+ until,
106
+ recordCount: packed.records.length,
107
+ uncompressedBytes: packed.uncompressedBytes,
108
+ compressedBytes: packed.gzip.length,
109
+ }),
110
+ }
111
+ }
112
+
113
+ function fitToCaps(records: LogRecord[]): {
114
+ records: LogRecord[]
115
+ jsonl: string
116
+ gzip: Buffer
117
+ uncompressedBytes: number
118
+ truncated: boolean
119
+ } | null {
120
+ const lines = records.map((record) => JSON.stringify(record))
121
+ let start = 0
122
+ let truncated = false
123
+ while (start < lines.length) {
124
+ const jsonl = toJsonl(lines.slice(start))
125
+ const uncompressedBytes = Buffer.byteLength(jsonl)
126
+ if (uncompressedBytes > FEEDBACK_SLICE_UNCOMPRESSED_CAP) {
127
+ start += 1
128
+ truncated = true
129
+ continue
130
+ }
131
+ const gzip = gzipSync(jsonl)
132
+ if (gzip.length <= FEEDBACK_SLICE_COMPRESSED_CAP) {
133
+ return {
134
+ records: records.slice(start),
135
+ jsonl,
136
+ gzip,
137
+ uncompressedBytes,
138
+ truncated,
139
+ }
140
+ }
141
+ start += 1
142
+ truncated = true
143
+ }
144
+ return null
145
+ }
146
+
147
+ function emptySlice(since: number, until: number): PackedFeedbackSlice {
148
+ return {
149
+ records: [],
150
+ jsonl: '',
151
+ uncompressed_bytes: 0,
152
+ compressed_bytes: 0,
153
+ record_count: 0,
154
+ truncated: false,
155
+ since,
156
+ until,
157
+ schema_version: LOG_SCHEMA_VERSION,
158
+ consentSummary: consentSummary({
159
+ since,
160
+ until,
161
+ recordCount: 0,
162
+ uncompressedBytes: 0,
163
+ compressedBytes: 0,
164
+ }),
165
+ }
166
+ }
167
+
168
+ function toJsonl(lines: string[]): string {
169
+ if (lines.length === 0) return ''
170
+ return `${lines.join('\n')}\n`
171
+ }
172
+
173
+ function consentSummary(input: {
174
+ since: number
175
+ until: number
176
+ recordCount: number
177
+ uncompressedBytes: number
178
+ compressedBytes: number
179
+ }): string {
180
+ const from = new Date(input.since).toISOString()
181
+ const to = new Date(input.until).toISOString()
182
+ return (
183
+ `About to send ${String(input.recordCount)} log records from ${from} to ${to} ` +
184
+ `(${String(input.uncompressedBytes)} bytes uncompressed, ${String(input.compressedBytes)} bytes compressed). ` +
185
+ 'Credentials are already removed. Notification titles and the user\'s answers are in the log.'
186
+ )
187
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { refreshAfterMenuAction } from './readiness.js'
3
+
4
+ describe('refreshAfterMenuAction', () => {
5
+ it('does not re-assess after doctor, a test send, or a device list', () => {
6
+ expect(refreshAfterMenuAction('doctor', true)).toBeNull()
7
+ expect(refreshAfterMenuAction('test', true)).toBeNull()
8
+ expect(refreshAfterMenuAction('devices', true)).toBeNull()
9
+ })
10
+
11
+ it('does not re-assess when the action changed nothing', () => {
12
+ expect(refreshAfterMenuAction('account', false)).toBeNull()
13
+ expect(refreshAfterMenuAction('settings', false)).toBeNull()
14
+ expect(refreshAfterMenuAction('routing', false)).toBeNull()
15
+ expect(refreshAfterMenuAction('setup', false)).toBeNull()
16
+ })
17
+
18
+ it('re-probes the service only after an action that can change it', () => {
19
+ expect(refreshAfterMenuAction('setup', true)).toEqual(['local', 'remote'])
20
+ expect(refreshAfterMenuAction('account', true)).toEqual(['local', 'remote'])
21
+ expect(refreshAfterMenuAction('settings', true)).toEqual(['local'])
22
+ expect(refreshAfterMenuAction('routing', true)).toEqual(['local'])
23
+ expect(refreshAfterMenuAction('settings', true, { remote: true })).toEqual(['local', 'remote'])
24
+ })
25
+ })
package/src/readiness.ts CHANGED
@@ -86,3 +86,41 @@ export function isReady(readiness: Readiness): boolean {
86
86
  export function openItems(readiness: Readiness): ReadinessState[] {
87
87
  return readiness.states.filter((s) => s.status === 'gap' || s.status === 'optional-gap')
88
88
  }
89
+
90
+ /**
91
+ * Work an assessment can do.
92
+ *
93
+ * `local` is this machine's files: project config, hook installs, skill lock
94
+ * files. `remote` is everything that leaves the process — the keychain, the
95
+ * service, the account, devices, and delivery evidence. The split exists so a
96
+ * redraw after a settings change does not pay for a health check.
97
+ */
98
+ export type ReadinessRefresh = 'local' | 'remote'
99
+
100
+ /**
101
+ * After a menu action, which readiness work is worth doing.
102
+ *
103
+ * `null` means keep the assessment already in hand. Doctor is the report, not
104
+ * a mutation: the check it just ran is the redraw. A test send and a device
105
+ * list cannot change setup. Settings and hook wiring stay on this machine
106
+ * unless the caller says the service URL itself moved.
107
+ */
108
+ export function refreshAfterMenuAction(
109
+ action: 'setup' | 'account' | 'test' | 'devices' | 'settings' | 'routing' | 'doctor',
110
+ changed: boolean,
111
+ options: { remote?: boolean } = {},
112
+ ): readonly ReadinessRefresh[] | null {
113
+ switch (action) {
114
+ case 'doctor':
115
+ case 'test':
116
+ case 'devices':
117
+ return null
118
+ case 'setup':
119
+ case 'account':
120
+ return changed ? ['local', 'remote'] : null
121
+ case 'settings':
122
+ case 'routing':
123
+ if (!changed) return null
124
+ return options.remote === true ? ['local', 'remote'] : ['local']
125
+ }
126
+ }
package/src/release.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ /**
6
+ * What this build is, according to the manifest npm actually shipped.
7
+ *
8
+ * Read from disk rather than compiled in, because a constant baked into
9
+ * `dist/` is only ever as fresh as the last build. A stale `dist/` sitting
10
+ * next to a newer `package.json` is a real failure mode — it produces a CLI
11
+ * that reports the new version perfectly while running the old code — and the
12
+ * only defence that survives it is refusing to carry a second copy of the
13
+ * number at all.
14
+ *
15
+ * The manifest sits one level above both `src/` and `dist/`, so a single
16
+ * relative path resolves in a source checkout and in a published install.
17
+ *
18
+ * Returns null rather than guessing: a build that cannot establish its own
19
+ * identity has to say so, so callers can degrade honestly instead of
20
+ * asserting something they do not know.
21
+ */
22
+ export function packageVersion(): string | null {
23
+ try {
24
+ const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
25
+ const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
26
+ if (typeof parsed === 'object' && parsed !== null) {
27
+ const { version } = parsed as { version?: unknown }
28
+ if (typeof version === 'string' && version.length > 0) return version
29
+ }
30
+ } catch {
31
+ // Fall through: an unreadable manifest must not stop the CLI running.
32
+ }
33
+ return null
34
+ }
35
+
36
+ /**
37
+ * Where `npx skills add` fetches the agent skill from, as printed to users.
38
+ *
39
+ * Always an immutable tag naming this exact build. The skill and the CLI ship
40
+ * from one repository at one version, and the skill describes how to drive the
41
+ * CLI — so a moving ref would hand someone guidance for a tool they do not
42
+ * have. Deriving it from the version is what keeps the two from disagreeing;
43
+ * it used to be maintained by hand in three places, and the copies drifted.
44
+ *
45
+ * In the skills CLI grammar `owner/repo#ref` selects a Git ref, while
46
+ * `owner/repo@name` selects a skill — so the `#` here is load-bearing.
47
+ */
48
+ export function skillsSource(): string | null {
49
+ const version = packageVersion()
50
+ return version === null ? null : `Raidiant-io/notifai#v${version}`
51
+ }