@raidiant/notifai 0.1.8 → 0.2.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/README.md +1 -1
- package/dist/commands.d.ts +30 -12
- package/dist/commands.js +118 -37
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +7 -5
- package/dist/hooks.js +60 -19
- package/dist/hooks.js.map +1 -1
- package/dist/main.js +35 -3
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +19 -26
- package/dist/send.js +35 -45
- package/dist/send.js.map +1 -1
- package/package.json +9 -9
- package/src/commands.test.ts +108 -13
- package/src/commands.ts +144 -37
- package/src/hooks.test.ts +102 -4
- package/src/hooks.ts +79 -24
- package/src/install-hooks.test.ts +8 -15
- package/src/main.ts +41 -4
- package/src/send.ts +50 -48
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ banner and is shown in the companion app for when you sit down.
|
|
|
35
35
|
## Asking you a question
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
notifai ask "Deploy the migration to production?" --choice
|
|
38
|
+
notifai ask "Deploy the migration to production?" --choice Yes --choice No
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
The agent registers the question, ends its turn, and your answer comes
|
package/dist/commands.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ListRepliesResponse, type Platform, type ReplyView } from '@raidiant/notifai-protocol';
|
|
1
|
+
import { type ListRepliesResponse, type Platform, type QuestionT, type ReplyView } from '@raidiant/notifai-protocol';
|
|
2
2
|
import { type ApiClient, type ClientOptions } from './client.js';
|
|
3
3
|
import type { CredentialStore } from './credentials.js';
|
|
4
4
|
import { type Readiness } from './readiness.js';
|
|
@@ -112,16 +112,15 @@ interface ReplyWaitResult {
|
|
|
112
112
|
/** Loop over server-capped long polls until a reply arrives or the caller's deadline passes. */
|
|
113
113
|
export declare function waitForReply(client: ApiClient, requestId: string, options: ReplyWaitOptions): Promise<ReplyWaitResult>;
|
|
114
114
|
/**
|
|
115
|
-
* The
|
|
115
|
+
* The correction note for a wait that saw more than one answer.
|
|
116
116
|
*
|
|
117
|
-
* A
|
|
118
|
-
* answer
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* answer between the first reply landing and its retirement arriving.
|
|
117
|
+
* A later reply that conflicts with an earlier one is a correction, so the
|
|
118
|
+
* latest answer is the one that counts. The server retires the question on
|
|
119
|
+
* the other devices the moment one answers, which makes this rare; it is
|
|
120
|
+
* still reachable, because a device can answer between the first reply
|
|
121
|
+
* landing and its retirement arriving.
|
|
123
122
|
*
|
|
124
|
-
* Ordered by seq, so `replies
|
|
123
|
+
* Ordered by seq, so `replies.at(-1)` is the answer to act on.
|
|
125
124
|
*/
|
|
126
125
|
export declare function contradictingAnswer(replies: ReplyView[]): string | null;
|
|
127
126
|
export declare function statusCommand(deps: CommandDeps, requestId: string, flags: {
|
|
@@ -155,16 +154,35 @@ export declare function hookRunCommand(deps: CommandDeps, event: string, readStd
|
|
|
155
154
|
*/
|
|
156
155
|
export declare function describeHookFailure(err: unknown): string[];
|
|
157
156
|
export interface AskFlags {
|
|
158
|
-
choice?: string
|
|
157
|
+
choice?: string[];
|
|
158
|
+
/** The single question is multi-select: several answers may be chosen. */
|
|
159
|
+
multi?: boolean;
|
|
160
|
+
/** Long-form markdown context; shown in the app, never on the banner. */
|
|
161
|
+
detail?: string;
|
|
162
|
+
/** Raw JSON for a multi-question form; replaces the positional question. */
|
|
163
|
+
form?: string;
|
|
159
164
|
session?: string;
|
|
160
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Turn ask input into the question set that will ride the push. Everything is
|
|
168
|
+
* validated here, at registration, because the push happens inside a hook
|
|
169
|
+
* where a rejection becomes a stderr note the agent never reads.
|
|
170
|
+
*/
|
|
171
|
+
export declare function buildQuestions(flags: AskFlags, question: string | undefined): {
|
|
172
|
+
ok: true;
|
|
173
|
+
questions: QuestionT[];
|
|
174
|
+
detail?: string;
|
|
175
|
+
} | {
|
|
176
|
+
ok: false;
|
|
177
|
+
error: string;
|
|
178
|
+
};
|
|
161
179
|
/**
|
|
162
180
|
* Registers a question for turn-end routing under the user's presence config.
|
|
163
181
|
* Returns immediately so the agent can ask in prose and end its turn. With the
|
|
164
182
|
* default presence gate, recent keyboard or mouse activity keeps it local;
|
|
165
183
|
* `require_idle = false` intentionally permits a push while the user is active.
|
|
166
184
|
*/
|
|
167
|
-
export declare function askCommand(deps: CommandDeps, question: string, flags: AskFlags): number;
|
|
185
|
+
export declare function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number;
|
|
168
186
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
169
187
|
export declare function closeCommand(deps: CommandDeps, requestId: string): Promise<number>;
|
|
170
188
|
export interface HooksInstallFlags {
|
|
@@ -191,7 +209,7 @@ export declare function configSetCommand(deps: CommandDeps, key: string, rawValu
|
|
|
191
209
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
192
210
|
* Keep this immutable and public because the command is printed to users.
|
|
193
211
|
*/
|
|
194
|
-
export declare const SKILLS_SOURCE = "Raidiant-io/notifai#v0.
|
|
212
|
+
export declare const SKILLS_SOURCE = "Raidiant-io/notifai#v0.2.0";
|
|
195
213
|
/** Derive a contract-valid project slug from a directory name. */
|
|
196
214
|
export declare function projectSlugFrom(name: string): string;
|
|
197
215
|
export interface InitFlags {
|
package/dist/commands.js
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSyn
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
7
|
-
import { CAPABILITIES_V1, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
|
|
7
|
+
import { CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
|
|
8
8
|
import { sha256Hex } from '@raidiant/notifai-protocol/node';
|
|
9
9
|
import { ApiCallError, NetworkError, createClient, } from './client.js';
|
|
10
10
|
import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, findProjectConfigPath, findProjectLocalConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
|
|
@@ -13,7 +13,7 @@ import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, p
|
|
|
13
13
|
import { readIdleSeconds } from './idle.js';
|
|
14
14
|
import { HARNESSES, applyPlan, blockingHookTimeoutSeconds, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
|
|
15
15
|
import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
|
|
16
|
-
import { CHOICE_USAGE,
|
|
16
|
+
import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.js';
|
|
17
17
|
export const EXIT = {
|
|
18
18
|
ok: 0,
|
|
19
19
|
failed: 1,
|
|
@@ -550,29 +550,27 @@ function printReplies(deps, replies) {
|
|
|
550
550
|
deps.io.err(contradiction);
|
|
551
551
|
}
|
|
552
552
|
/**
|
|
553
|
-
* The
|
|
553
|
+
* The correction note for a wait that saw more than one answer.
|
|
554
554
|
*
|
|
555
|
-
* A
|
|
556
|
-
* answer
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
* answer between the first reply landing and its retirement arriving.
|
|
555
|
+
* A later reply that conflicts with an earlier one is a correction, so the
|
|
556
|
+
* latest answer is the one that counts. The server retires the question on
|
|
557
|
+
* the other devices the moment one answers, which makes this rare; it is
|
|
558
|
+
* still reachable, because a device can answer between the first reply
|
|
559
|
+
* landing and its retirement arriving.
|
|
561
560
|
*
|
|
562
|
-
* Ordered by seq, so `replies
|
|
561
|
+
* Ordered by seq, so `replies.at(-1)` is the answer to act on.
|
|
563
562
|
*/
|
|
564
563
|
export function contradictingAnswer(replies) {
|
|
565
|
-
const winner = replies
|
|
564
|
+
const winner = replies.at(-1);
|
|
566
565
|
if (winner === undefined || replies.length < 2)
|
|
567
566
|
return null;
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (disagreeing.length === 0)
|
|
567
|
+
const key = (reply) => reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text;
|
|
568
|
+
const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner));
|
|
569
|
+
if (corrected.length === 0)
|
|
572
570
|
return null;
|
|
573
|
-
const names = [...new Set(
|
|
574
|
-
return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts —
|
|
575
|
-
|
|
571
|
+
const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ');
|
|
572
|
+
return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
|
|
573
|
+
`answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`);
|
|
576
574
|
}
|
|
577
575
|
function printNoReply(deps, requestId, expiresAt) {
|
|
578
576
|
// A harness hook may have retired this question, in which case promising an
|
|
@@ -841,6 +839,88 @@ function rejectedPaths(details) {
|
|
|
841
839
|
.filter((path) => path !== null && path !== '')
|
|
842
840
|
.slice(0, 5);
|
|
843
841
|
}
|
|
842
|
+
/**
|
|
843
|
+
* Turn ask input into the question set that will ride the push. Everything is
|
|
844
|
+
* validated here, at registration, because the push happens inside a hook
|
|
845
|
+
* where a rejection becomes a stderr note the agent never reads.
|
|
846
|
+
*/
|
|
847
|
+
export function buildQuestions(flags, question) {
|
|
848
|
+
if (flags.form !== undefined) {
|
|
849
|
+
if (question !== undefined || flags.choice?.length || flags.multi) {
|
|
850
|
+
return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' };
|
|
851
|
+
}
|
|
852
|
+
let parsed;
|
|
853
|
+
try {
|
|
854
|
+
parsed = JSON.parse(flags.form);
|
|
855
|
+
}
|
|
856
|
+
catch {
|
|
857
|
+
return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' };
|
|
858
|
+
}
|
|
859
|
+
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.questions)) {
|
|
860
|
+
return { ok: false, error: '--form needs a "questions" array (1-4 entries).' };
|
|
861
|
+
}
|
|
862
|
+
const form = parsed;
|
|
863
|
+
if (form.questions.length < 1 || form.questions.length > REPLY_MAX_QUESTIONS) {
|
|
864
|
+
return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` };
|
|
865
|
+
}
|
|
866
|
+
if (form.detail !== undefined && typeof form.detail !== 'string') {
|
|
867
|
+
return { ok: false, error: '"detail" must be a markdown string.' };
|
|
868
|
+
}
|
|
869
|
+
const questions = [];
|
|
870
|
+
const usedIds = new Set();
|
|
871
|
+
for (const [index, entry] of form.questions.entries()) {
|
|
872
|
+
if (typeof entry !== 'object' || entry === null || typeof entry.text !== 'string') {
|
|
873
|
+
return { ok: false, error: `Question ${index + 1} needs a "text" string.` };
|
|
874
|
+
}
|
|
875
|
+
const spec = entry;
|
|
876
|
+
const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds);
|
|
877
|
+
if ('error' in built)
|
|
878
|
+
return { ok: false, error: `Question ${index + 1}: ${built.error}` };
|
|
879
|
+
questions.push(built.question);
|
|
880
|
+
}
|
|
881
|
+
return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) };
|
|
882
|
+
}
|
|
883
|
+
if (question === undefined || question.trim() === '') {
|
|
884
|
+
return { ok: false, error: 'The question cannot be empty.' };
|
|
885
|
+
}
|
|
886
|
+
const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set());
|
|
887
|
+
if ('error' in built)
|
|
888
|
+
return { ok: false, error: built.error };
|
|
889
|
+
return {
|
|
890
|
+
ok: true,
|
|
891
|
+
questions: [built.question],
|
|
892
|
+
...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
|
|
896
|
+
const trimmed = text.trim();
|
|
897
|
+
if (trimmed === '')
|
|
898
|
+
return { error: 'the question text cannot be empty.' };
|
|
899
|
+
if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
|
|
900
|
+
return {
|
|
901
|
+
error: `a question must be readable where it is answered: keep it within ` +
|
|
902
|
+
`${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in detail.`,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
const choices = parseChoices(choiceLabels);
|
|
906
|
+
if (choices === 'invalid')
|
|
907
|
+
return { error: CHOICE_USAGE };
|
|
908
|
+
if (multi && choices === null) {
|
|
909
|
+
return { error: '--multi needs answers to select between; add --choice.' };
|
|
910
|
+
}
|
|
911
|
+
let id = slugify(trimmed);
|
|
912
|
+
if (id === '' || usedIds.has(id))
|
|
913
|
+
id = `q${index + 1}`;
|
|
914
|
+
usedIds.add(id);
|
|
915
|
+
return {
|
|
916
|
+
question: {
|
|
917
|
+
id,
|
|
918
|
+
text: trimmed,
|
|
919
|
+
...(choices !== null ? { choices } : {}),
|
|
920
|
+
...(multi ? { multi: true } : {}),
|
|
921
|
+
},
|
|
922
|
+
};
|
|
923
|
+
}
|
|
844
924
|
/**
|
|
845
925
|
* Registers a question for turn-end routing under the user's presence config.
|
|
846
926
|
* Returns immediately so the agent can ask in prose and end its turn. With the
|
|
@@ -895,37 +975,38 @@ export function askCommand(deps, question, flags) {
|
|
|
895
975
|
deps.io.err(line);
|
|
896
976
|
return EXIT.usage;
|
|
897
977
|
}
|
|
898
|
-
if (question.trim() === '') {
|
|
899
|
-
deps.io.err('The question cannot be empty.');
|
|
900
|
-
return EXIT.usage;
|
|
901
|
-
}
|
|
902
978
|
// Validate here, not at push time. The push happens inside a hook, where a
|
|
903
979
|
// rejection becomes a stderr note the agent never reads — so a malformed
|
|
904
|
-
//
|
|
905
|
-
const
|
|
906
|
-
if (
|
|
907
|
-
deps.io.err(
|
|
980
|
+
// question set would look like it registered fine and then silently never ask.
|
|
981
|
+
const built = buildQuestions(flags, question);
|
|
982
|
+
if (!built.ok) {
|
|
983
|
+
deps.io.err(built.error);
|
|
908
984
|
return EXIT.usage;
|
|
909
985
|
}
|
|
910
986
|
try {
|
|
911
987
|
registerQuestion(sessionId, deps.env, {
|
|
912
|
-
question:
|
|
913
|
-
|
|
988
|
+
question: built.questions[0].text,
|
|
989
|
+
questions: built.questions,
|
|
990
|
+
...(built.detail !== undefined ? { detail: built.detail } : {}),
|
|
914
991
|
}, (deps.now ?? Date.now)());
|
|
915
992
|
}
|
|
916
993
|
catch (err) {
|
|
917
994
|
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
|
|
918
995
|
return EXIT.failed;
|
|
919
996
|
}
|
|
920
|
-
|
|
921
|
-
|
|
997
|
+
for (const [index, entry] of built.questions.entries()) {
|
|
998
|
+
const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
|
|
999
|
+
if (entry.choices !== undefined) {
|
|
1000
|
+
const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
|
|
1001
|
+
deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
|
|
1002
|
+
}
|
|
1003
|
+
else if (built.questions.length > 1) {
|
|
1004
|
+
deps.io.out(`${prefix}${entry.text} — free text`);
|
|
1005
|
+
}
|
|
922
1006
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
if (ambiguous !== null)
|
|
927
|
-
deps.io.out(ambiguous);
|
|
928
|
-
deps.io.out('Question registered. Ask it in the conversation as usual and end your turn.');
|
|
1007
|
+
deps.io.out(built.questions.length > 1
|
|
1008
|
+
? `${built.questions.length} questions registered as one form. Ask them in the conversation as usual and end your turn.`
|
|
1009
|
+
: 'Question registered. Ask it in the conversation as usual and end your turn.');
|
|
929
1010
|
return EXIT.ok;
|
|
930
1011
|
}
|
|
931
1012
|
function activeHarnessSession(env) {
|
|
@@ -1381,7 +1462,7 @@ export async function configSetCommand(deps, key, rawValue, flags) {
|
|
|
1381
1462
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
1382
1463
|
* Keep this immutable and public because the command is printed to users.
|
|
1383
1464
|
*/
|
|
1384
|
-
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.
|
|
1465
|
+
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.0';
|
|
1385
1466
|
function skillSourceParts() {
|
|
1386
1467
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
|
|
1387
1468
|
return match === null ? null : { source: match[1], ref: match[2] };
|