@raidiant/notifai 0.5.1 → 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.
- package/CHANGELOG.md +165 -0
- package/README.md +11 -4
- package/dist/commands.d.ts +16 -1
- package/dist/commands.js +371 -157
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +2 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +19 -8
- package/dist/config.js +50 -7
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +1 -0
- package/dist/harnesses.js +6 -0
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +10 -1
- package/dist/hook-adapter.js +79 -10
- package/dist/hook-adapter.js.map +1 -1
- package/dist/install-hooks.d.ts +54 -17
- package/dist/install-hooks.js +258 -97
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +32 -1
- package/dist/interactive.js +163 -47
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +8 -8
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +1 -1
- package/dist/native-skills.js +23 -58
- package/dist/native-skills.js.map +1 -1
- package/dist/pack-feedback-slice.d.ts +35 -0
- package/dist/pack-feedback-slice.js +126 -0
- package/dist/pack-feedback-slice.js.map +1 -0
- package/dist/readiness.d.ts +20 -0
- package/dist/readiness.js +24 -0
- package/dist/readiness.js.map +1 -1
- package/package.json +3 -2
- package/src/commands.test.ts +364 -31
- package/src/commands.ts +450 -177
- package/src/config-schema.ts +2 -17
- package/src/config.test.ts +29 -4
- package/src/config.ts +67 -11
- package/src/harnesses.ts +7 -0
- package/src/hook-adapter.test.ts +43 -1
- package/src/hook-adapter.ts +83 -11
- package/src/hooks.test.ts +4 -5
- package/src/install-hooks.test.ts +154 -2
- package/src/install-hooks.ts +231 -44
- package/src/interactive.test.ts +338 -0
- package/src/interactive.ts +221 -49
- package/src/main.ts +8 -8
- package/src/native-skills.test.ts +75 -0
- package/src/native-skills.ts +31 -75
- package/src/pack-feedback-slice.test.ts +212 -0
- package/src/pack-feedback-slice.ts +187 -0
- package/src/readiness.test.ts +25 -0
- package/src/readiness.ts +38 -0
- package/src/skill.test.ts +43 -0
- package/src/ui/config-view.test.ts +5 -4
package/src/native-skills.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
)
|
|
87
|
-
|
|
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
|
|
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
|
|
89
|
+
name,
|
|
120
90
|
scope,
|
|
121
|
-
path:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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:
|
|
109
|
+
stdio: 'inherit',
|
|
140
110
|
})
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
124
|
+
return run(args, { cwd: options.cwd, env: options.env })
|
|
164
125
|
},
|
|
165
126
|
|
|
166
127
|
async list(scope, cwd, env) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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/skill.test.ts
CHANGED
|
@@ -20,6 +20,33 @@ describe('Notifai agent skill', () => {
|
|
|
20
20
|
expect(skill).toContain('| question (`--reply`) | `attention` | `active` |')
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
+
it('makes the agent the setup wizard', () => {
|
|
24
|
+
expect(skill.indexOf('## Set Notifai up')).toBeGreaterThan(skill.indexOf('## When to notify'))
|
|
25
|
+
expect(skill.indexOf('## Set Notifai up')).toBeLessThan(skill.indexOf('## Compose and send'))
|
|
26
|
+
expect(skill).toContain('Never tell the user to run')
|
|
27
|
+
expect(skill).toContain('notifai init')
|
|
28
|
+
expect(skill).toContain('notifai hooks install')
|
|
29
|
+
expect(skill).toContain('notifai doctor')
|
|
30
|
+
expect(skill).toContain('notifai login')
|
|
31
|
+
expect(skill).toContain('Ask the user only for decisions a process cannot make')
|
|
32
|
+
expect(skill).not.toContain('relay the exact action')
|
|
33
|
+
expect(harnessReference).toContain('install hooks yourself')
|
|
34
|
+
expect(harnessReference).toContain('run `notifai login` yourself')
|
|
35
|
+
expect(harnessReference).not.toContain('the user must run `notifai login`')
|
|
36
|
+
expect(harnessReference).not.toContain('do not install them without being asked')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('treats --sound and --level as overrides, not completeness', () => {
|
|
40
|
+
expect(skill).toMatch(/Never pass `--sound` or `--level` unless/)
|
|
41
|
+
const examples = [...skill.matchAll(/```(?:bash)?\n([\s\S]*?)```/g)].map((match) => match[1])
|
|
42
|
+
const sendExamples = examples.filter((example) => example.includes('notifai send'))
|
|
43
|
+
expect(sendExamples.length).toBeGreaterThan(0)
|
|
44
|
+
for (const example of sendExamples) {
|
|
45
|
+
expect(example).not.toMatch(/(^|\s)--sound\b/)
|
|
46
|
+
expect(example).not.toMatch(/(^|\s)--level\b/)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
23
50
|
it('documents reply lifecycle, routing, session minting, and doctor exits', () => {
|
|
24
51
|
for (const required of [
|
|
25
52
|
'--reply-choice',
|
|
@@ -57,6 +84,22 @@ describe('Notifai agent skill', () => {
|
|
|
57
84
|
expect(askGuidance).toMatch(/without asking the user to\s+confirm again/)
|
|
58
85
|
})
|
|
59
86
|
|
|
87
|
+
it('teaches agents to report config JSON fields instead of paraphrasing defaults', () => {
|
|
88
|
+
expect(skill).toContain('notifai config show --json')
|
|
89
|
+
expect(skill).toContain('{ value, source, summary }')
|
|
90
|
+
expect(skill).toContain('Do not paraphrase')
|
|
91
|
+
expect(skill).not.toContain('config.local.toml')
|
|
92
|
+
expect(skill).toContain('outside the repository')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('mentions npx only as a pinned alternative to a real install', () => {
|
|
96
|
+
expect(skill).toContain('npm install -g @raidiant/notifai')
|
|
97
|
+
expect(skill.indexOf('npm install -g @raidiant/notifai')).toBeLessThan(
|
|
98
|
+
skill.indexOf('npx --yes @raidiant/notifai@'),
|
|
99
|
+
)
|
|
100
|
+
expect(skill).toContain('do not present npx as the default')
|
|
101
|
+
})
|
|
102
|
+
|
|
60
103
|
it('tells the agent where to look when something did not happen', () => {
|
|
61
104
|
// The log's whole value is diagnostic, so the skill has to name the case
|
|
62
105
|
// that produces it — a question that was registered and never travelled.
|