@raidiant/notifai 1.1.0 → 2.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 +33 -0
- package/dist/client.d.ts +5 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +8 -1
- package/dist/commands.js +120 -15
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +15 -1
- package/dist/hooks.js +182 -12
- package/dist/hooks.js.map +1 -1
- package/dist/logging.d.ts +1 -1
- package/dist/logging.js +4 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +14 -3
- package/dist/main.js.map +1 -1
- package/package.json +10 -9
- package/src/client.test.ts +53 -0
- package/src/client.ts +24 -0
- package/src/commands.test.ts +252 -5
- package/src/commands.ts +158 -13
- package/src/hooks.test.ts +150 -2
- package/src/hooks.ts +240 -12
- package/src/logging.ts +4 -0
- package/src/main.ts +17 -2
- package/src/skill.test.ts +32 -1
package/dist/hooks.d.ts
CHANGED
|
@@ -76,6 +76,17 @@ export interface SessionState {
|
|
|
76
76
|
* reaches the harness replays it instead of erasing it.
|
|
77
77
|
*/
|
|
78
78
|
accepted?: AcceptedAnswerDelivery;
|
|
79
|
+
/**
|
|
80
|
+
* Required Agent Acknowledgements that the resumed agent still owes. This is
|
|
81
|
+
* separate from answer delivery: the answer journal may settle as soon as a
|
|
82
|
+
* harness accepts the continuation, while this obligation must survive until
|
|
83
|
+
* the service confirms the agent-authored follow-up exists.
|
|
84
|
+
*/
|
|
85
|
+
acknowledgement_due?: AcknowledgementDue[];
|
|
86
|
+
}
|
|
87
|
+
export interface AcknowledgementDue {
|
|
88
|
+
request_id: string;
|
|
89
|
+
recorded_at: number;
|
|
79
90
|
}
|
|
80
91
|
interface AcceptedAnswerDelivery {
|
|
81
92
|
answers: AnsweredPending[];
|
|
@@ -288,7 +299,7 @@ export interface HookContext {
|
|
|
288
299
|
* the user sees in the terminal — but it is free to be rewritten, and a log a
|
|
289
300
|
* filter cannot rely on is a log nobody filters.
|
|
290
301
|
*/
|
|
291
|
-
export type GateReason = 'no-session' | 'no-question' | 'answered' | 'already-asked' | 'continuation-repeat' | 'continuation-limit' | 'delivery-limit' | 'notifications-off' | 'claimed-elsewhere' | 'no-devices' | 'proceeding';
|
|
302
|
+
export type GateReason = 'no-session' | 'no-question' | 'answered' | 'already-asked' | 'continuation-repeat' | 'continuation-limit' | 'delivery-limit' | 'acknowledgement-required' | 'notifications-off' | 'claimed-elsewhere' | 'no-devices' | 'proceeding';
|
|
292
303
|
export type HookHarness = Harness;
|
|
293
304
|
/**
|
|
294
305
|
* Everything retirement needs. Narrower than a HookContext on purpose:
|
|
@@ -331,7 +342,10 @@ interface AnsweredPending {
|
|
|
331
342
|
pending: PendingQuestion;
|
|
332
343
|
reply: ReplyView;
|
|
333
344
|
replies: ReplyView[];
|
|
345
|
+
/** Immutable server snapshot for this request, known after a replies/close response. */
|
|
346
|
+
agent_acknowledgement_required?: boolean | undefined;
|
|
334
347
|
}
|
|
348
|
+
export declare function clearAcknowledgementObligation(sessionId: string, env: NodeJS.ProcessEnv, requestId: string): boolean;
|
|
335
349
|
/**
|
|
336
350
|
* Records presence and retires anything still asking on companion devices. This is the
|
|
337
351
|
* "answered in the terminal" case from the original design: we cannot tell
|
package/dist/hooks.js
CHANGED
|
@@ -541,7 +541,7 @@ async function answerableDevices(ctx) {
|
|
|
541
541
|
return devices
|
|
542
542
|
.filter((device) => (device.platform === 'ios' || device.platform === 'macos') &&
|
|
543
543
|
device.registration_healthy &&
|
|
544
|
-
device.reply_protocol_version ===
|
|
544
|
+
device.reply_protocol_version === 2)
|
|
545
545
|
.filter((device) => configured === null || configured.includes(device.device_id))
|
|
546
546
|
.map((device) => device.device_id);
|
|
547
547
|
}
|
|
@@ -814,7 +814,14 @@ async function finalizePendings(ctx, pending) {
|
|
|
814
814
|
function finalizedAnswer(finalized) {
|
|
815
815
|
const replies = finalized.response?.replies ?? [];
|
|
816
816
|
const reply = replies.at(-1);
|
|
817
|
-
return reply === undefined
|
|
817
|
+
return reply === undefined
|
|
818
|
+
? null
|
|
819
|
+
: {
|
|
820
|
+
pending: finalized.pending,
|
|
821
|
+
reply,
|
|
822
|
+
replies,
|
|
823
|
+
agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
|
|
824
|
+
};
|
|
818
825
|
}
|
|
819
826
|
/** Persist and surface one answer without letting the two representations drift. */
|
|
820
827
|
function reportAnswer(ctx, notes, answered, late) {
|
|
@@ -902,12 +909,35 @@ function answerContext(answered) {
|
|
|
902
909
|
* tied to the question that asked it, with a truthful note about anything
|
|
903
910
|
* still waiting.
|
|
904
911
|
*/
|
|
912
|
+
function acknowledgementCommand(requestId) {
|
|
913
|
+
return `notifai acknowledge ${requestId} --text <text>`;
|
|
914
|
+
}
|
|
915
|
+
function acknowledgementContext(answered) {
|
|
916
|
+
const due = answered.filter((entry) => entry.agent_acknowledgement_required === true && entry.pending.request_id !== undefined);
|
|
917
|
+
if (due.length === 0) {
|
|
918
|
+
return ' Agent Acknowledgement is not required for the answered request(s).';
|
|
919
|
+
}
|
|
920
|
+
if (due.length === 1) {
|
|
921
|
+
const requestId = due[0].pending.request_id;
|
|
922
|
+
return (` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
|
|
923
|
+
`\`${acknowledgementCommand(requestId)}\` with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient.`);
|
|
924
|
+
}
|
|
925
|
+
const commands = due
|
|
926
|
+
.map((entry) => {
|
|
927
|
+
const requestId = entry.pending.request_id;
|
|
928
|
+
return `- ${requestId}: \`${acknowledgementCommand(requestId)}\``;
|
|
929
|
+
})
|
|
930
|
+
.join('\n');
|
|
931
|
+
return (` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below with non-empty text saying what concrete work you will do because of that reply; a bare acknowledgement is insufficient:\n` +
|
|
932
|
+
commands);
|
|
933
|
+
}
|
|
905
934
|
function answersContext(answered, remaining) {
|
|
906
935
|
const tail = remaining > 0
|
|
907
936
|
? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
|
|
908
937
|
: '';
|
|
938
|
+
const guidance = acknowledgementContext(answered);
|
|
909
939
|
if (answered.length === 1) {
|
|
910
|
-
return answerContext(answered[0]) + tail;
|
|
940
|
+
return answerContext(answered[0]) + tail + guidance;
|
|
911
941
|
}
|
|
912
942
|
const lines = answered.map(({ pending, replies }) => {
|
|
913
943
|
const latest = replies.at(-1);
|
|
@@ -918,7 +948,7 @@ function answersContext(answered, remaining) {
|
|
|
918
948
|
const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `;
|
|
919
949
|
return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`;
|
|
920
950
|
});
|
|
921
|
-
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}`;
|
|
951
|
+
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}${guidance}`;
|
|
922
952
|
}
|
|
923
953
|
function stopAnswerOutput(context) {
|
|
924
954
|
return JSON.stringify({
|
|
@@ -940,6 +970,15 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
|
|
|
940
970
|
};
|
|
941
971
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
942
972
|
const pendingRemaining = pendingList(current).filter((entry) => !answered.some(({ pending }) => isSamePending(entry, pending)));
|
|
973
|
+
const acknowledgementDue = [...(current.acknowledgement_due ?? [])];
|
|
974
|
+
for (const answer of answered) {
|
|
975
|
+
const requestId = answer.pending.request_id;
|
|
976
|
+
if (answer.agent_acknowledgement_required === true &&
|
|
977
|
+
requestId !== undefined &&
|
|
978
|
+
!acknowledgementDue.some((entry) => entry.request_id === requestId)) {
|
|
979
|
+
acknowledgementDue.push({ request_id: requestId, recorded_at: accepted.recorded_at });
|
|
980
|
+
}
|
|
981
|
+
}
|
|
943
982
|
const next = {
|
|
944
983
|
...current,
|
|
945
984
|
accepted,
|
|
@@ -948,6 +987,10 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
|
|
|
948
987
|
next.pending = pendingRemaining;
|
|
949
988
|
else
|
|
950
989
|
delete next.pending;
|
|
990
|
+
if (acknowledgementDue.length > 0)
|
|
991
|
+
next.acknowledgement_due = acknowledgementDue;
|
|
992
|
+
else
|
|
993
|
+
delete next.acknowledgement_due;
|
|
951
994
|
return next;
|
|
952
995
|
});
|
|
953
996
|
return accepted;
|
|
@@ -956,6 +999,58 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
|
|
|
956
999
|
function amendAcceptedAnswers(ctx, sessionId, amend) {
|
|
957
1000
|
updateSessionState(sessionId, ctx.env, (current) => current.accepted === undefined ? current : { ...current, accepted: amend(current.accepted) });
|
|
958
1001
|
}
|
|
1002
|
+
export function clearAcknowledgementObligation(sessionId, env, requestId) {
|
|
1003
|
+
let cleared = false;
|
|
1004
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1005
|
+
const due = current.acknowledgement_due ?? [];
|
|
1006
|
+
const remaining = due.filter((entry) => entry.request_id !== requestId);
|
|
1007
|
+
cleared = remaining.length !== due.length;
|
|
1008
|
+
if (!cleared)
|
|
1009
|
+
return current;
|
|
1010
|
+
const next = { ...current };
|
|
1011
|
+
if (remaining.length > 0)
|
|
1012
|
+
next.acknowledgement_due = remaining;
|
|
1013
|
+
else
|
|
1014
|
+
delete next.acknowledgement_due;
|
|
1015
|
+
return next;
|
|
1016
|
+
});
|
|
1017
|
+
return cleared;
|
|
1018
|
+
}
|
|
1019
|
+
function acknowledgementBlockContext(due) {
|
|
1020
|
+
const commands = due
|
|
1021
|
+
.map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id)}\``)
|
|
1022
|
+
.join('\n');
|
|
1023
|
+
return (`Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
|
|
1024
|
+
`Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'} with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient:\n${commands}`);
|
|
1025
|
+
}
|
|
1026
|
+
async function reconcileAcknowledgementObligations(ctx, sessionId, due) {
|
|
1027
|
+
const unresolved = [];
|
|
1028
|
+
for (const obligation of due) {
|
|
1029
|
+
try {
|
|
1030
|
+
const snapshot = await ctx.client.agentAcknowledgement(obligation.request_id, {
|
|
1031
|
+
waitSeconds: 0,
|
|
1032
|
+
});
|
|
1033
|
+
if (snapshot.agent_acknowledgement_required === false ||
|
|
1034
|
+
snapshot.agent_acknowledgement !== null) {
|
|
1035
|
+
clearAcknowledgementObligation(sessionId, ctx.env, obligation.request_id);
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
unresolved.push(obligation);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
catch (err) {
|
|
1042
|
+
unresolved.push(obligation);
|
|
1043
|
+
ctx.log?.error('hook.gate', {
|
|
1044
|
+
verdict: 'held',
|
|
1045
|
+
reason: 'acknowledgement-required',
|
|
1046
|
+
stage: 'reconcile-failed',
|
|
1047
|
+
request_id: obligation.request_id,
|
|
1048
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
return unresolved;
|
|
1053
|
+
}
|
|
959
1054
|
/**
|
|
960
1055
|
* Retire the answered questions and close the journal.
|
|
961
1056
|
*
|
|
@@ -1050,7 +1145,13 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
1050
1145
|
if (response === null) {
|
|
1051
1146
|
notes.push(`could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`);
|
|
1052
1147
|
}
|
|
1053
|
-
return {
|
|
1148
|
+
return {
|
|
1149
|
+
...entry,
|
|
1150
|
+
replies,
|
|
1151
|
+
reply: replies.at(-1),
|
|
1152
|
+
agent_acknowledgement_required: response?.agent_acknowledgement_required ??
|
|
1153
|
+
entry.agent_acknowledgement_required,
|
|
1154
|
+
};
|
|
1054
1155
|
});
|
|
1055
1156
|
if (answered.length > 0) {
|
|
1056
1157
|
stageAcceptedAnswers(ctx, sessionId, lateAnswers, 0);
|
|
@@ -1164,6 +1265,23 @@ export async function runEscalationWaiter(ctx, options) {
|
|
|
1164
1265
|
let state = readSessionState(sessionId, ctx.env);
|
|
1165
1266
|
if (state.accepted !== undefined) {
|
|
1166
1267
|
const accepted = state.accepted;
|
|
1268
|
+
if (envelope.stop_hook_active === true && pendingList(state).length > 0) {
|
|
1269
|
+
const due = await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due ?? []);
|
|
1270
|
+
if (due.length > 0) {
|
|
1271
|
+
gate(ctx, 'held', 'acknowledgement-required', {
|
|
1272
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
1273
|
+
});
|
|
1274
|
+
return {
|
|
1275
|
+
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1276
|
+
notes,
|
|
1277
|
+
log: {
|
|
1278
|
+
stage: 'acknowledgement-required',
|
|
1279
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
1280
|
+
},
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
state = readSessionState(sessionId, ctx.env);
|
|
1284
|
+
}
|
|
1167
1285
|
// Acknowledgement is per route, because routes end in different places.
|
|
1168
1286
|
//
|
|
1169
1287
|
// A blocking continuation ends at this process's stdout, which the
|
|
@@ -1184,6 +1302,39 @@ export async function runEscalationWaiter(ctx, options) {
|
|
|
1184
1302
|
settleAcceptedAnswers(ctx, sessionId, accepted);
|
|
1185
1303
|
settledAnswerThisPass = true;
|
|
1186
1304
|
state = readSessionState(sessionId, ctx.env);
|
|
1305
|
+
const currentAcceptedIds = new Set(accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]));
|
|
1306
|
+
const due = await reconcileAcknowledgementObligations(ctx, sessionId, (state.acknowledgement_due ?? []).filter((entry) => currentAcceptedIds.has(entry.request_id)));
|
|
1307
|
+
if (due.length > 0) {
|
|
1308
|
+
gate(ctx, 'held', 'acknowledgement-required', {
|
|
1309
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
1310
|
+
});
|
|
1311
|
+
return {
|
|
1312
|
+
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1313
|
+
notes,
|
|
1314
|
+
log: {
|
|
1315
|
+
stage: 'acknowledgement-required',
|
|
1316
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
1317
|
+
},
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
state = readSessionState(sessionId, ctx.env);
|
|
1321
|
+
}
|
|
1322
|
+
if (state.accepted === undefined && (state.acknowledgement_due?.length ?? 0) > 0) {
|
|
1323
|
+
const due = await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due);
|
|
1324
|
+
if (due.length > 0) {
|
|
1325
|
+
gate(ctx, 'held', 'acknowledgement-required', {
|
|
1326
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
1327
|
+
});
|
|
1328
|
+
return {
|
|
1329
|
+
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1330
|
+
notes,
|
|
1331
|
+
log: {
|
|
1332
|
+
stage: 'acknowledgement-required',
|
|
1333
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
1334
|
+
},
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
state = readSessionState(sessionId, ctx.env);
|
|
1187
1338
|
}
|
|
1188
1339
|
const pending = pendingList(state);
|
|
1189
1340
|
if (pending.length === 0) {
|
|
@@ -1332,7 +1483,13 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
1332
1483
|
if (response === null) {
|
|
1333
1484
|
notes.push(`could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`);
|
|
1334
1485
|
}
|
|
1335
|
-
return {
|
|
1486
|
+
return {
|
|
1487
|
+
...entry,
|
|
1488
|
+
replies,
|
|
1489
|
+
reply: replies.at(-1),
|
|
1490
|
+
agent_acknowledgement_required: response?.agent_acknowledgement_required ??
|
|
1491
|
+
entry.agent_acknowledgement_required,
|
|
1492
|
+
};
|
|
1336
1493
|
});
|
|
1337
1494
|
const accepted = stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
|
|
1338
1495
|
for (const answer of authoritative)
|
|
@@ -1666,7 +1823,12 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1666
1823
|
// unreachable, never discard the durable reply already observed.
|
|
1667
1824
|
const finalReplies = finalized.response?.replies ?? [];
|
|
1668
1825
|
const authoritative = finalReplies.length > 0 ? finalReplies : replies;
|
|
1669
|
-
answered.push({
|
|
1826
|
+
answered.push({
|
|
1827
|
+
pending: entry,
|
|
1828
|
+
reply: authoritative.at(-1),
|
|
1829
|
+
replies: authoritative,
|
|
1830
|
+
agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
|
|
1831
|
+
});
|
|
1670
1832
|
if (finalized.response === null) {
|
|
1671
1833
|
notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
|
|
1672
1834
|
}
|
|
@@ -1717,8 +1879,13 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1717
1879
|
orphanRetirements(env, orphans, label !== undefined && label !== '' ? label : sessionId, now);
|
|
1718
1880
|
notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
|
|
1719
1881
|
}
|
|
1720
|
-
if (state.accepted !== undefined) {
|
|
1721
|
-
const preserved = {
|
|
1882
|
+
if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
|
|
1883
|
+
const preserved = {};
|
|
1884
|
+
if (state.accepted !== undefined)
|
|
1885
|
+
preserved.accepted = state.accepted;
|
|
1886
|
+
if (state.acknowledgement_due !== undefined && state.acknowledgement_due.length > 0) {
|
|
1887
|
+
preserved.acknowledgement_due = state.acknowledgement_due;
|
|
1888
|
+
}
|
|
1722
1889
|
if (state.last_prompt_at !== undefined)
|
|
1723
1890
|
preserved.last_prompt_at = state.last_prompt_at;
|
|
1724
1891
|
if (state.last_stop_at !== undefined)
|
|
@@ -1726,13 +1893,16 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1726
1893
|
if (state.continuation !== undefined)
|
|
1727
1894
|
preserved.continuation = state.continuation;
|
|
1728
1895
|
writeSessionState(sessionId, env, preserved);
|
|
1729
|
-
notes.push(
|
|
1896
|
+
notes.push(state.accepted !== undefined
|
|
1897
|
+
? 'preserved an accepted device answer for this exact session to resume'
|
|
1898
|
+
: 'preserved required Agent Acknowledgement obligations for this exact session');
|
|
1730
1899
|
return {
|
|
1731
1900
|
notes,
|
|
1732
1901
|
log: {
|
|
1733
|
-
outcome: 'answer-preserved',
|
|
1902
|
+
outcome: state.accepted !== undefined ? 'answer-preserved' : 'acknowledgement-preserved',
|
|
1734
1903
|
queued_retirements: orphans.length,
|
|
1735
|
-
accepted_answers: state.accepted
|
|
1904
|
+
accepted_answers: state.accepted?.answers.length ?? 0,
|
|
1905
|
+
acknowledgement_due: state.acknowledgement_due?.length ?? 0,
|
|
1736
1906
|
},
|
|
1737
1907
|
};
|
|
1738
1908
|
}
|