@raidiant/notifai 9.3.0 → 10.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 +26 -0
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/commands-agent-sessions.d.ts +6 -0
- package/dist/commands-agent-sessions.js +51 -0
- package/dist/commands-agent-sessions.js.map +1 -0
- package/dist/commands-config.js +11 -4
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +3 -0
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +42 -4
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +15 -2
- package/dist/commands-harness-context.js +32 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +4 -3
- package/dist/commands-hooks.js +17 -11
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-send-support.js +18 -1
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +2 -2
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-sounds.d.ts +12 -0
- package/dist/commands-sounds.js +35 -0
- package/dist/commands-sounds.js.map +1 -0
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +2 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +5 -2
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -4
- package/dist/config.js +5 -2
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +4 -1
- package/dist/credentials.js.map +1 -1
- package/dist/harnesses.d.ts +49 -7
- package/dist/harnesses.js +90 -24
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +2 -2
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -11
- package/dist/install-hooks.js +3 -4
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +2 -2
- package/dist/orca-session-title.d.ts +10 -0
- package/dist/orca-session-title.js +98 -0
- package/dist/orca-session-title.js.map +1 -0
- package/dist/program.d.ts +3 -1
- package/dist/program.js +28 -5
- package/dist/program.js.map +1 -1
- package/dist/send.js +11 -14
- package/dist/send.js.map +1 -1
- package/dist/session-labels.d.ts +18 -2
- package/dist/session-labels.js +79 -8
- package/dist/session-labels.js.map +1 -1
- package/dist/sound-ref.d.ts +23 -0
- package/dist/sound-ref.js +28 -0
- package/dist/sound-ref.js.map +1 -0
- package/package.json +3 -3
- package/src/client.test.ts +12 -0
- package/src/client.ts +8 -0
- package/src/commands-agent-sessions.ts +72 -0
- package/src/commands-config.ts +10 -4
- package/src/commands-core.ts +3 -0
- package/src/commands-doctor.ts +48 -4
- package/src/commands-harness-context.ts +48 -2
- package/src/commands-hooks.ts +36 -18
- package/src/commands-send-support.ts +23 -1
- package/src/commands-send.ts +2 -2
- package/src/commands-sounds.test.ts +106 -0
- package/src/commands-sounds.ts +49 -0
- package/src/commands.test.ts +344 -11
- package/src/commands.ts +5 -0
- package/src/config-schema.test.ts +1 -0
- package/src/config-schema.ts +5 -2
- package/src/config.test.ts +42 -20
- package/src/config.ts +6 -5
- package/src/credentials.test.ts +20 -0
- package/src/credentials.ts +4 -1
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
- package/src/harness-integration.test.ts +212 -0
- package/src/harnesses.test.ts +107 -8
- package/src/harnesses.ts +111 -30
- package/src/hooks.test.ts +4 -3
- package/src/hooks.ts +4 -4
- package/src/install-hooks.test.ts +5 -4
- package/src/install-hooks.ts +19 -21
- package/src/invocation-context.ts +2 -2
- package/src/orca-session-title.test.ts +54 -0
- package/src/orca-session-title.ts +103 -0
- package/src/program.test.ts +27 -0
- package/src/program.ts +38 -4
- package/src/send.ts +10 -19
- package/src/session-labels.test.ts +58 -9
- package/src/session-labels.ts +95 -12
- package/src/skill.test.ts +14 -3
- package/src/sound-ref.test.ts +31 -0
- package/src/sound-ref.ts +34 -0
- package/src/ui/config-view.test.ts +1 -0
package/src/session-labels.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { copyFileSync, existsSync, readFileSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { SESSION_LABEL_MAX_LENGTH } from '@raidiant/notifai-protocol'
|
|
5
5
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
6
6
|
import { stateDir } from './config.js'
|
|
7
7
|
import { withFileLock } from './file-lock.js'
|
|
8
8
|
import { generatedSessionLabel } from './generated-session-label.js'
|
|
9
|
-
import { HARNESS_LABELS, type
|
|
9
|
+
import { HARNESS_LABELS, type SourceContextHarness } from './harnesses.js'
|
|
10
10
|
|
|
11
11
|
const STORE_VERSION = 1
|
|
12
12
|
const MONTHS = [
|
|
@@ -35,7 +35,7 @@ interface StoredSessionLabel {
|
|
|
35
35
|
label: string
|
|
36
36
|
source: SessionLabelSource
|
|
37
37
|
first_seen_at: number
|
|
38
|
-
harness?:
|
|
38
|
+
harness?: SourceContextHarness
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
interface SessionLabelStore {
|
|
@@ -46,13 +46,21 @@ interface SessionLabelStore {
|
|
|
46
46
|
export interface SessionLabelInput {
|
|
47
47
|
env: NodeJS.ProcessEnv
|
|
48
48
|
sessionId: string
|
|
49
|
-
harness?:
|
|
49
|
+
harness?: SourceContextHarness
|
|
50
50
|
explicitLabel?: string
|
|
51
51
|
harnessLabel?: string
|
|
52
52
|
harnessLabelPending?: boolean
|
|
53
53
|
now?: number
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export interface SessionLabelRenameInput {
|
|
57
|
+
env: NodeJS.ProcessEnv
|
|
58
|
+
sessionId: string
|
|
59
|
+
label: string
|
|
60
|
+
harness?: SourceContextHarness
|
|
61
|
+
now?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
export type SessionLabelResolution =
|
|
57
65
|
| { ok: true; label: string; source: SessionLabelSource }
|
|
58
66
|
| { ok: false; error: string }
|
|
@@ -70,6 +78,24 @@ function emptyStore(): SessionLabelStore {
|
|
|
70
78
|
return { version: STORE_VERSION, sessions: {} }
|
|
71
79
|
}
|
|
72
80
|
|
|
81
|
+
function quarantineInvalidStore(file: string, raw: string): void {
|
|
82
|
+
const digest = createHash('sha256').update(raw).digest('hex')
|
|
83
|
+
const backup = path.join(path.dirname(file), `session-labels.invalid-${digest}.json`)
|
|
84
|
+
if (existsSync(backup)) {
|
|
85
|
+
if (readFileSync(backup, 'utf8') !== raw) {
|
|
86
|
+
throw new Error('the session-name store recovery backup does not match its content hash')
|
|
87
|
+
}
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
copyFileSync(file, backup)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function recoverInvalidStore(file: string, raw: string, store: SessionLabelStore): SessionLabelStore {
|
|
94
|
+
quarantineInvalidStore(file, raw)
|
|
95
|
+
writeStore(file, store)
|
|
96
|
+
return store
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
function storedRecord(candidate: unknown): StoredSessionLabel | null {
|
|
74
100
|
if (typeof candidate !== 'object' || candidate === null) return null
|
|
75
101
|
const value = candidate as Partial<StoredSessionLabel>
|
|
@@ -95,33 +121,42 @@ function storedRecord(candidate: unknown): StoredSessionLabel | null {
|
|
|
95
121
|
|
|
96
122
|
function readStore(file: string): SessionLabelStore {
|
|
97
123
|
if (!existsSync(file)) return emptyStore()
|
|
98
|
-
let
|
|
124
|
+
let rawText: string
|
|
99
125
|
try {
|
|
100
|
-
|
|
126
|
+
rawText = readFileSync(file, 'utf8')
|
|
101
127
|
} catch (err) {
|
|
102
128
|
throw new Error(
|
|
103
129
|
`the session-name store is unreadable: ${err instanceof Error ? err.message : String(err)}`,
|
|
104
130
|
)
|
|
105
131
|
}
|
|
132
|
+
let parsed: unknown
|
|
133
|
+
try {
|
|
134
|
+
parsed = JSON.parse(rawText)
|
|
135
|
+
} catch {
|
|
136
|
+
return recoverInvalidStore(file, rawText, emptyStore())
|
|
137
|
+
}
|
|
106
138
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
107
|
-
|
|
139
|
+
return recoverInvalidStore(file, rawText, emptyStore())
|
|
108
140
|
}
|
|
109
141
|
const raw = parsed as { version?: unknown; sessions?: unknown }
|
|
110
142
|
if (raw.version !== STORE_VERSION) {
|
|
111
|
-
|
|
143
|
+
return recoverInvalidStore(file, rawText, emptyStore())
|
|
112
144
|
}
|
|
113
145
|
if (typeof raw.sessions !== 'object' || raw.sessions === null) {
|
|
114
|
-
|
|
146
|
+
return recoverInvalidStore(file, rawText, emptyStore())
|
|
115
147
|
}
|
|
116
148
|
const sessions: Record<string, StoredSessionLabel> = {}
|
|
149
|
+
let invalidRecordFound = false
|
|
117
150
|
for (const [key, candidate] of Object.entries(raw.sessions)) {
|
|
118
151
|
const record = storedRecord(candidate)
|
|
119
152
|
if (!/^[a-f0-9]{64}$/.test(key) || record === null) {
|
|
120
|
-
|
|
153
|
+
invalidRecordFound = true
|
|
154
|
+
continue
|
|
121
155
|
}
|
|
122
156
|
sessions[key] = record
|
|
123
157
|
}
|
|
124
|
-
|
|
158
|
+
const store = { version: STORE_VERSION, sessions } satisfies SessionLabelStore
|
|
159
|
+
return invalidRecordFound ? recoverInvalidStore(file, rawText, store) : store
|
|
125
160
|
}
|
|
126
161
|
|
|
127
162
|
function writeStore(file: string, store: SessionLabelStore): void {
|
|
@@ -203,6 +238,20 @@ function explicitCandidate(
|
|
|
203
238
|
return { label: normalized, source: 'explicit' }
|
|
204
239
|
}
|
|
205
240
|
|
|
241
|
+
export function normalizeExplicitSessionLabel(
|
|
242
|
+
input: Pick<SessionLabelRenameInput, 'env' | 'sessionId' | 'label'>,
|
|
243
|
+
): { ok: true; label: string } | { ok: false; error: string } {
|
|
244
|
+
const candidate = explicitCandidate(input.label, {
|
|
245
|
+
env: input.env,
|
|
246
|
+
sessionId: input.sessionId,
|
|
247
|
+
})
|
|
248
|
+
if (candidate === null) {
|
|
249
|
+
return { ok: false, error: '--session-label must not be empty.' }
|
|
250
|
+
}
|
|
251
|
+
if ('error' in candidate) return { ok: false, error: candidate.error }
|
|
252
|
+
return { ok: true, label: candidate.label }
|
|
253
|
+
}
|
|
254
|
+
|
|
206
255
|
function harnessLabelIsPending(
|
|
207
256
|
value: string | undefined,
|
|
208
257
|
input: SessionLabelInput,
|
|
@@ -260,7 +309,7 @@ function collisionKey(value: string): string {
|
|
|
260
309
|
function uniqueLabel(
|
|
261
310
|
base: string,
|
|
262
311
|
source: SessionLabelSource,
|
|
263
|
-
harness:
|
|
312
|
+
harness: SourceContextHarness | undefined,
|
|
264
313
|
now: number,
|
|
265
314
|
used: ReadonlySet<string>,
|
|
266
315
|
): string {
|
|
@@ -389,3 +438,37 @@ export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResol
|
|
|
389
438
|
}
|
|
390
439
|
}
|
|
391
440
|
}
|
|
441
|
+
|
|
442
|
+
/** Replace the local frozen label only after the Account-authoritative rename succeeds. */
|
|
443
|
+
export function renameStoredSessionLabel(
|
|
444
|
+
input: SessionLabelRenameInput,
|
|
445
|
+
): SessionLabelResolution {
|
|
446
|
+
const normalized = normalizeExplicitSessionLabel(input)
|
|
447
|
+
if (!normalized.ok) return normalized
|
|
448
|
+
const file = storePath(input.env)
|
|
449
|
+
const key = sessionKey(input.sessionId)
|
|
450
|
+
const now = input.now ?? Date.now()
|
|
451
|
+
try {
|
|
452
|
+
return withFileLock(`${file}.lock`, () => {
|
|
453
|
+
const store = readStore(file)
|
|
454
|
+
const existing = store.sessions[key]
|
|
455
|
+
store.sessions[key] = {
|
|
456
|
+
label: normalized.label,
|
|
457
|
+
source: 'explicit',
|
|
458
|
+
first_seen_at: existing?.first_seen_at ?? now,
|
|
459
|
+
...(input.harness === undefined
|
|
460
|
+
? existing?.harness === undefined
|
|
461
|
+
? {}
|
|
462
|
+
: { harness: existing.harness }
|
|
463
|
+
: { harness: input.harness }),
|
|
464
|
+
}
|
|
465
|
+
writeStore(file, store)
|
|
466
|
+
return { ok: true, label: normalized.label, source: 'explicit' }
|
|
467
|
+
})
|
|
468
|
+
} catch (err) {
|
|
469
|
+
return {
|
|
470
|
+
ok: false,
|
|
471
|
+
error: `The Account label changed, but this machine could not save it locally: ${err instanceof Error ? err.message : String(err)}`,
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
package/src/skill.test.ts
CHANGED
|
@@ -225,7 +225,7 @@ describe('Notifai agent skill', () => {
|
|
|
225
225
|
expect(send).toMatch(/kind and the project travel as their own\s+fields, never in it/i)
|
|
226
226
|
})
|
|
227
227
|
|
|
228
|
-
it('
|
|
228
|
+
it('keeps initial labels stable and teaches the narrow exact-session rename exception', () => {
|
|
229
229
|
const send = section('## Send')
|
|
230
230
|
expect(send).toContain('--session-label')
|
|
231
231
|
expect(send).toMatch(/2-6 words/i)
|
|
@@ -239,6 +239,11 @@ describe('Notifai agent skill', () => {
|
|
|
239
239
|
expect(send).toMatch(/generated fallback name[\s\S]*replaced by a later\s+semantic name/i)
|
|
240
240
|
expect(send).toMatch(/never pass\s+`--session-id`/i)
|
|
241
241
|
expect(send).toMatch(/identifier, hash, or\s+filesystem path/i)
|
|
242
|
+
expect(send).toContain('notifai session rename')
|
|
243
|
+
expect(send).toMatch(/job changed completely[\s\S]{0,100}current\s+name would now mislead/i)
|
|
244
|
+
expect(send).toMatch(/never rename for milestones, ordinary progress/i)
|
|
245
|
+
expect(send).toMatch(/accepts no Agent Session id/i)
|
|
246
|
+
expect(send).toMatch(/active harness proves the exact current Agent Session/i)
|
|
242
247
|
})
|
|
243
248
|
|
|
244
249
|
it('never teaches by enumerating environments the CLI can detect itself', () => {
|
|
@@ -247,7 +252,7 @@ describe('Notifai agent skill', () => {
|
|
|
247
252
|
// Harness names may appear only in setup/routing material, where the
|
|
248
253
|
// harness IS the subject.
|
|
249
254
|
for (const heading of ['## Decide whether to notify', '## Send', '## Ask a question', '## When the answer arrives']) {
|
|
250
|
-
expect(section(heading), heading).not.toMatch(/OpenCode|Orca|Cursor|Codex/)
|
|
255
|
+
expect(section(heading), heading).not.toMatch(/OpenCode|Orca|Cursor|Codex|Hermes/)
|
|
251
256
|
}
|
|
252
257
|
})
|
|
253
258
|
|
|
@@ -383,6 +388,7 @@ describe('Notifai agent skill', () => {
|
|
|
383
388
|
'notifai guidance',
|
|
384
389
|
'notifai logs',
|
|
385
390
|
'notifai status',
|
|
391
|
+
'notifai sounds',
|
|
386
392
|
]) {
|
|
387
393
|
expect(skill, `${command} is unreachable from the skill`).toContain(command)
|
|
388
394
|
}
|
|
@@ -391,9 +397,14 @@ describe('Notifai agent skill', () => {
|
|
|
391
397
|
it('moves per-harness mechanics into progressive disclosure', () => {
|
|
392
398
|
expect(skill).toContain('[Harness setup and recovery](references/harness-setup.md)')
|
|
393
399
|
expect(skill).toMatch(/read it when you are\s+installing hooks or diagnosing routing/i)
|
|
394
|
-
for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode']) {
|
|
400
|
+
for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode', 'Hermes']) {
|
|
395
401
|
expect(harnessReference).toContain(`**${harness}:**`)
|
|
396
402
|
}
|
|
403
|
+
expect(harnessReference).toMatch(/exact `HERMES_SESSION_ID`/i)
|
|
404
|
+
expect(harnessReference).toMatch(/Do not run `notifai hooks install --harness hermes`/i)
|
|
405
|
+
expect(harnessReference).toMatch(/blocking `notifai send --reply`/i)
|
|
406
|
+
expect(harnessReference).toMatch(/npx skills/i)
|
|
407
|
+
expect(harnessReference).toMatch(/no Hermes plugin may vendor/i)
|
|
397
408
|
// The agent runs what a process can run; the human only does the human part.
|
|
398
409
|
expect(harnessReference).toMatch(/let init install and assess\s+the hooks/i)
|
|
399
410
|
expect(harnessReference).toContain('--setup-scope <project|global>')
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { SOUND_NAME_MAX_LENGTH, SOUND_REF_MAX_LENGTH } from '@raidiant/notifai-protocol'
|
|
3
|
+
import { isCliSoundRef, unknownSoundMessage } from './sound-ref.js'
|
|
4
|
+
|
|
5
|
+
describe('isCliSoundRef', () => {
|
|
6
|
+
it('accepts shipped names and the silent spelling', () => {
|
|
7
|
+
for (const ref of ['default', 'done', 'attention', 'alert', 'none']) {
|
|
8
|
+
expect(isCliSoundRef(ref), ref).toBe(true)
|
|
9
|
+
}
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('accepts an Account custom sound id and a display name', () => {
|
|
13
|
+
expect(isCliSoundRef('snd_01HQTEST')).toBe(true)
|
|
14
|
+
expect(isCliSoundRef('Kitchen timer')).toBe(true)
|
|
15
|
+
expect(isCliSoundRef('airhorn')).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('rejects empty, blank, and over-long values', () => {
|
|
19
|
+
expect(isCliSoundRef('')).toBe(false)
|
|
20
|
+
expect(isCliSoundRef(' ')).toBe(false)
|
|
21
|
+
expect(isCliSoundRef('n'.repeat(SOUND_NAME_MAX_LENGTH + 1))).toBe(false)
|
|
22
|
+
expect(isCliSoundRef(`snd_${'a'.repeat(SOUND_REF_MAX_LENGTH)}`)).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('unknownSoundMessage', () => {
|
|
27
|
+
it('points at the list command rather than only the shipped set', () => {
|
|
28
|
+
expect(unknownSoundMessage('nope')).toContain('notifai sounds')
|
|
29
|
+
expect(unknownSoundMessage('nope')).toContain('custom sound')
|
|
30
|
+
})
|
|
31
|
+
})
|
package/src/sound-ref.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLI_SOUNDS,
|
|
3
|
+
SOUND_NAME_MAX_LENGTH,
|
|
4
|
+
SOUND_REF_MAX_LENGTH,
|
|
5
|
+
isCustomSoundId,
|
|
6
|
+
} from '@raidiant/notifai-protocol'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What `--sound` and the saved `sound` key accept: shipped semantic names,
|
|
10
|
+
* the CLI silent spelling, an Account custom sound id, or a custom display name.
|
|
11
|
+
*/
|
|
12
|
+
export function isCliSoundRef(value: string): boolean {
|
|
13
|
+
if ((CLI_SOUNDS as readonly string[]).includes(value)) return true
|
|
14
|
+
if (value.length < 1 || value.length > SOUND_REF_MAX_LENGTH) return false
|
|
15
|
+
if (isCustomSoundId(value)) return true
|
|
16
|
+
const trimmed = value.trim()
|
|
17
|
+
return trimmed.length >= 1 && trimmed.length <= SOUND_NAME_MAX_LENGTH
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function unknownSoundMessage(sound: string): string {
|
|
21
|
+
return (
|
|
22
|
+
`Unknown sound "${sound}" — use a shipped name (${CLI_SOUNDS.join(', ')}), ` +
|
|
23
|
+
'a custom sound id, or a custom sound name. Run `notifai sounds` to list what this Account can play.'
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Shipped names plus the CLI silent spelling, with User-facing labels. */
|
|
28
|
+
export const SHIPPED_SOUND_LISTING = [
|
|
29
|
+
{ ref: 'default', name: 'Device default' },
|
|
30
|
+
{ ref: 'done', name: 'completion chime' },
|
|
31
|
+
{ ref: 'attention', name: 'attention tone' },
|
|
32
|
+
{ ref: 'alert', name: 'most insistent tone' },
|
|
33
|
+
{ ref: 'none', name: 'silent' },
|
|
34
|
+
] as const
|