guidinghand 0.1.0 → 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 +27 -5
- package/dist/cjs/index.d.ts +38 -8
- package/dist/cjs/index.js +45 -18
- package/dist/esm/index.d.ts +38 -8
- package/dist/esm/index.js +45 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ await gh.sessions.waitForConnection(session.session_id);
|
|
|
26
26
|
const task = await gh.tasks.run(session.session_id, {
|
|
27
27
|
prompt: 'Turn on Dark Mode',
|
|
28
28
|
onEvent: (e) => console.log(e.type, e.message),
|
|
29
|
-
onQuestion: async (q) => 'Work', // q.question, q.options
|
|
29
|
+
onQuestion: async (q) => 'Work', // q.question, q.options (leave it out to let the customer answer on their screen)
|
|
30
30
|
onApproval: async (a) => a.risk !== 'high', // true/'approve' or false/'deny'
|
|
31
31
|
});
|
|
32
32
|
|
|
@@ -36,11 +36,12 @@ console.log('Replay:', task.replay_url);
|
|
|
36
36
|
|
|
37
37
|
## Agents
|
|
38
38
|
|
|
39
|
-
An agent is your configuration: instructions (added under GuidingHand's own rules, which always win), effort
|
|
39
|
+
An agent is your configuration: instructions (added under GuidingHand's own rules, which always win), effort, a greeting for the invite page, and what the customer sees and may do on their screen. Every org has a `default` agent.
|
|
40
40
|
|
|
41
41
|
```ts
|
|
42
42
|
await gh.agents.create({ agent_id: 'billing', name: 'Billing help', instructions: 'Open the Billing app from the Dock.', effort: 'medium', greeting: 'Hi, this is Acme billing.' });
|
|
43
43
|
await gh.agents.update('billing', { effort: 'high' });
|
|
44
|
+
await gh.agents.update('billing', { display_name: 'Acme Support', narration: true, customer_answers: false }); // on their screen
|
|
44
45
|
const { data } = await gh.agents.list();
|
|
45
46
|
await gh.agents.delete('billing');
|
|
46
47
|
```
|
|
@@ -72,6 +73,26 @@ const page = await gh.tasks.list({ status: 'completed', limit: 50 }); // { da
|
|
|
72
73
|
for await (const t of gh.tasks.listAll({ agent_id: 'billing' })) { /* every page */ }
|
|
73
74
|
```
|
|
74
75
|
|
|
76
|
+
## Questions the customer answers
|
|
77
|
+
|
|
78
|
+
Unless the agent turns it off (`customer_answers: false`), the agent's questions also appear on the customer's screen, and they can answer them there. `question.customer_can_answer` says whether they can answer the open one. The first answer is used, from them or from you.
|
|
79
|
+
|
|
80
|
+
- Without `onQuestion`, `run()` leaves those questions to the customer and keeps following the task (pass `timeout` to give up eventually). It throws `NeedsInputError` only for a question they can't answer.
|
|
81
|
+
- With `onQuestion`, return `null` to leave one to them (say, when `q.customer_can_answer` is true). If they answer while your handler works, your answer is refused with a 409 and `run()` carries on.
|
|
82
|
+
- `respond()` after they answered throws `ConflictError` with `extra.code === 'already_answered'` and `extra.answered_by === 'customer'`.
|
|
83
|
+
- The `answer` event has `data.answered_by` (`'customer'` or `'operator'`), and the `task.question_answered` webhook carries the answer and who gave it.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const task = await gh.tasks.run(sessionId, {
|
|
87
|
+
prompt: 'Set up the office printer',
|
|
88
|
+
onEvent: (e) => { if (e.type === 'answer') console.log(`${e.data?.answered_by} answered: ${e.message}`); },
|
|
89
|
+
onQuestion: async (q) => (q.customer_can_answer ? null : askMyTeam(q.question, q.options)),
|
|
90
|
+
onApproval: async (a) => askMyTeamToApprove(a.action),
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Approvals always come from your team.
|
|
95
|
+
|
|
75
96
|
## Recordings
|
|
76
97
|
|
|
77
98
|
```ts
|
|
@@ -84,7 +105,7 @@ The console replays them with the agent's cursor at `task.replay_url`.
|
|
|
84
105
|
## Webhooks
|
|
85
106
|
|
|
86
107
|
```ts
|
|
87
|
-
const { secret } = await gh.webhook.update({ url: 'https://example.com/guidinghand', events: ['task.completed', 'task.waiting_for_user'] });
|
|
108
|
+
const { secret } = await gh.webhook.update({ url: 'https://example.com/guidinghand', events: ['task.completed', 'task.waiting_for_user', 'task.question_answered'] });
|
|
88
109
|
```
|
|
89
110
|
|
|
90
111
|
Verify each delivery with the raw request body:
|
|
@@ -95,6 +116,7 @@ import { verifyWebhook } from 'guidinghand';
|
|
|
95
116
|
app.post('/guidinghand', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
96
117
|
const event = await verifyWebhook(req.body, req.header('GuidingHand-Signature'), process.env.GUIDINGHAND_WEBHOOK_SECRET);
|
|
97
118
|
if (event.type === 'task.completed') console.log(event.data.task.result);
|
|
119
|
+
if (event.type === 'task.question_answered') console.log(event.data.answer.answered_by, event.data.answer.answer);
|
|
98
120
|
res.sendStatus(200);
|
|
99
121
|
});
|
|
100
122
|
```
|
|
@@ -110,11 +132,11 @@ Every error is a `GuidingHandError` with `status`, `type`, `message` and `extra`
|
|
|
110
132
|
| `PaymentRequiredError` | 402 | the org's plan doesn't allow it (free minutes used up) |
|
|
111
133
|
| `PermissionDeniedError` | 403 | your role can't do this |
|
|
112
134
|
| `NotFoundError` | 404 | not in this org |
|
|
113
|
-
| `ConflictError` | 409 | no computer connected, a task already running (`extra.active_task_id`), nothing pending |
|
|
135
|
+
| `ConflictError` | 409 | no computer connected, a task already running (`extra.active_task_id`), nothing pending, or already answered (`extra.code: 'already_answered'`, `extra.answered_by`) |
|
|
114
136
|
| `RateLimitError` | 429 | too many requests, or the plan's tasks at once |
|
|
115
137
|
| `APIError` | 5xx | our side |
|
|
116
138
|
| `APIConnectionError`, `TimeoutError` | | network |
|
|
117
|
-
| `NeedsInputError` | | `run()` got a question or approval without a handler (`error.task`) |
|
|
139
|
+
| `NeedsInputError` | | `run()` got a question (that the customer can't answer) or an approval without a handler (`error.task`) |
|
|
118
140
|
| `SessionExpiredError` | | `waitForConnection()` on a code that expired |
|
|
119
141
|
|
|
120
142
|
Reads, deletes and starts with a `request_id` are retried on connection errors, 429 and 5xx (`maxRetries`, default 2).
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.2.0";
|
|
2
2
|
export type Metadata = Record<string, string>;
|
|
3
3
|
export type Effort = 'low' | 'medium' | 'high' | null;
|
|
4
4
|
export type Agent = {
|
|
@@ -8,6 +8,12 @@ export type Agent = {
|
|
|
8
8
|
instructions: string;
|
|
9
9
|
effort: Effort;
|
|
10
10
|
greeting: string;
|
|
11
|
+
/** What the app calls the agent on the person's screen; '' is "GuidingHand". */
|
|
12
|
+
display_name: string;
|
|
13
|
+
/** The app shows the agent's thoughts and steps on the person's screen as it works. */
|
|
14
|
+
narration: boolean;
|
|
15
|
+
/** The person at the computer can answer the agent's questions in the app (the team still can; the first answer is used). Approvals always stay with the team. */
|
|
16
|
+
customer_answers: boolean;
|
|
11
17
|
is_default: boolean;
|
|
12
18
|
invite_url_template: string;
|
|
13
19
|
created_at: string | null;
|
|
@@ -38,11 +44,13 @@ export type Session = {
|
|
|
38
44
|
latest_task_id?: string | null;
|
|
39
45
|
};
|
|
40
46
|
export type TaskStatus = 'queued' | 'running' | 'waiting_for_user' | 'waiting_for_approval' | 'completed' | 'failed' | 'stopped';
|
|
47
|
+
/** customer_can_answer: the person at the computer can answer it on their screen too (the first answer is used). */
|
|
41
48
|
export type Question = {
|
|
42
49
|
type: 'question';
|
|
43
50
|
question_id: string;
|
|
44
51
|
question: string;
|
|
45
52
|
options: string[];
|
|
53
|
+
customer_can_answer: boolean;
|
|
46
54
|
};
|
|
47
55
|
export type Approval = {
|
|
48
56
|
type: 'approval';
|
|
@@ -51,6 +59,9 @@ export type Approval = {
|
|
|
51
59
|
risk: 'low' | 'medium' | 'high';
|
|
52
60
|
};
|
|
53
61
|
export type EventType = 'started' | 'progress' | 'thinking' | 'action' | 'message' | 'question' | 'answer' | 'approval_required' | 'approved' | 'denied' | 'completed' | 'error' | 'stopped';
|
|
62
|
+
/** Who answered a question: the person at the computer ('customer', in the app) or your team ('operator': the API or the console). */
|
|
63
|
+
export type AnsweredBy = 'customer' | 'operator';
|
|
64
|
+
/** data: e.g. { action } for 'action', { question_id, question, options } for 'question', { question_id, answered_by } for 'answer' (whose message is the answer). */
|
|
54
65
|
export type TaskEvent = {
|
|
55
66
|
cursor: number;
|
|
56
67
|
type: EventType;
|
|
@@ -96,7 +107,7 @@ export type Recording = {
|
|
|
96
107
|
task_id: string;
|
|
97
108
|
frames: Frame[];
|
|
98
109
|
};
|
|
99
|
-
export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | 'task.waiting_for_approval' | 'task.completed' | 'task.failed' | 'task.stopped';
|
|
110
|
+
export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | 'task.question_answered' | 'task.waiting_for_approval' | 'task.completed' | 'task.failed' | 'task.stopped';
|
|
100
111
|
export type Webhook = {
|
|
101
112
|
object: 'webhook';
|
|
102
113
|
url: string | null;
|
|
@@ -105,6 +116,12 @@ export type Webhook = {
|
|
|
105
116
|
secret?: string;
|
|
106
117
|
event_types: WebhookEventType[];
|
|
107
118
|
};
|
|
119
|
+
export type QuestionAnswer = {
|
|
120
|
+
question_id: string;
|
|
121
|
+
answer: string;
|
|
122
|
+
answered_by: AnsweredBy;
|
|
123
|
+
};
|
|
124
|
+
/** data.answer: for task.question_answered. */
|
|
108
125
|
export type WebhookEvent = {
|
|
109
126
|
id: string;
|
|
110
127
|
type: WebhookEventType;
|
|
@@ -113,6 +130,7 @@ export type WebhookEvent = {
|
|
|
113
130
|
data: {
|
|
114
131
|
task?: Task;
|
|
115
132
|
session?: Session;
|
|
133
|
+
answer?: QuestionAnswer;
|
|
116
134
|
};
|
|
117
135
|
};
|
|
118
136
|
export type Page<T> = {
|
|
@@ -141,6 +159,7 @@ export declare class PermissionDeniedError extends GuidingHandError {
|
|
|
141
159
|
}
|
|
142
160
|
export declare class NotFoundError extends GuidingHandError {
|
|
143
161
|
}
|
|
162
|
+
/** 409. From respond(): extra.code is 'already_answered' when someone answered first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
|
|
144
163
|
export declare class ConflictError extends GuidingHandError {
|
|
145
164
|
}
|
|
146
165
|
export declare class RateLimitError extends GuidingHandError {
|
|
@@ -153,7 +172,7 @@ export declare class TimeoutError extends GuidingHandError {
|
|
|
153
172
|
}
|
|
154
173
|
export declare class SessionExpiredError extends GuidingHandError {
|
|
155
174
|
}
|
|
156
|
-
/** A task waits on a question or approval and `run()` got no handler for it. */
|
|
175
|
+
/** A task waits on a question or approval and `run()` got no handler for it (a question the person at the computer can answer is left to them instead). */
|
|
157
176
|
export declare class NeedsInputError extends GuidingHandError {
|
|
158
177
|
task: Task;
|
|
159
178
|
constructor(task: Task);
|
|
@@ -205,6 +224,9 @@ export type AgentCreate = {
|
|
|
205
224
|
instructions?: string;
|
|
206
225
|
effort?: Effort;
|
|
207
226
|
greeting?: string;
|
|
227
|
+
display_name?: string;
|
|
228
|
+
narration?: boolean;
|
|
229
|
+
customer_answers?: boolean;
|
|
208
230
|
};
|
|
209
231
|
export type AgentUpdate = Partial<Omit<AgentCreate, 'agent_id'>>;
|
|
210
232
|
declare class Agents extends Resource {
|
|
@@ -264,8 +286,12 @@ export type Respond = {
|
|
|
264
286
|
export type RunOptions = TaskCreate & {
|
|
265
287
|
/** Every event, as it happens. */
|
|
266
288
|
onEvent?: (event: TaskEvent, task: Task) => void | Promise<void>;
|
|
267
|
-
/**
|
|
268
|
-
|
|
289
|
+
/**
|
|
290
|
+
* The agent asks something: return the answer. When the person at the computer can answer it on their screen
|
|
291
|
+
* (question.customer_can_answer), return null to leave it to them; without onQuestion, run() does that for
|
|
292
|
+
* every such question and throws NeedsInputError only for the others. If they answer first, yours is dropped.
|
|
293
|
+
*/
|
|
294
|
+
onQuestion?: (question: Question, task: Task) => string | null | undefined | Promise<string | null | undefined>;
|
|
269
295
|
/** The agent wants to do something consequential: return true (or 'approve') to let it, false (or 'deny', or { decision, note }) not to. */
|
|
270
296
|
onApproval?: (approval: Approval, task: Task) => boolean | 'approve' | 'deny' | {
|
|
271
297
|
decision: 'approve' | 'deny';
|
|
@@ -305,14 +331,18 @@ declare class Tasks extends Resource {
|
|
|
305
331
|
stream(taskId: string, { after }?: {
|
|
306
332
|
after?: number;
|
|
307
333
|
}): AsyncGenerator<TaskEvent, Task>;
|
|
308
|
-
/** Starts a task and sees it through: events to onEvent, questions to onQuestion, approvals to onApproval. Resolves with the finished task. */
|
|
334
|
+
/** Starts a task and sees it through: events to onEvent, questions to onQuestion (or the person at the computer), approvals to onApproval. Resolves with the finished task. */
|
|
309
335
|
run(sessionId: string, { onEvent, onQuestion, onApproval, timeout, ...params }: RunOptions): Promise<Task>;
|
|
310
336
|
}
|
|
311
337
|
declare class WebhookEndpoint extends Resource {
|
|
312
338
|
retrieve(): Promise<Webhook>;
|
|
313
|
-
/**
|
|
339
|
+
/**
|
|
340
|
+
* Sets the endpoint. Only the fields you pass change: without url the endpoint stays, without events the
|
|
341
|
+
* filter stays, so { rotate_secret: true } alone makes a new secret. The signing secret is in the response
|
|
342
|
+
* when it's new (or rotated), never again.
|
|
343
|
+
*/
|
|
314
344
|
update(params: {
|
|
315
|
-
url
|
|
345
|
+
url?: string;
|
|
316
346
|
events?: WebhookEventType[];
|
|
317
347
|
rotate_secret?: boolean;
|
|
318
348
|
}): Promise<Webhook>;
|
package/dist/cjs/index.js
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
// // send session.invite_url to the person at the computer, then:
|
|
9
9
|
// await gh.sessions.waitForConnection(session.session_id);
|
|
10
10
|
// const task = await gh.tasks.run(session.session_id, { prompt: 'Turn on Dark Mode', onQuestion: async (q) => 'Work', onApproval: async () => true });
|
|
11
|
+
// // (without onQuestion, questions the person at the computer can answer on their screen are left to them)
|
|
11
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
13
|
exports.GuidingHand = exports.WebhookVerificationError = exports.NeedsInputError = exports.SessionExpiredError = exports.TimeoutError = exports.APIConnectionError = exports.APIError = exports.RateLimitError = exports.ConflictError = exports.NotFoundError = exports.PermissionDeniedError = exports.PaymentRequiredError = exports.AuthenticationError = exports.InvalidRequestError = exports.GuidingHandError = exports.VERSION = void 0;
|
|
13
14
|
exports.verifyWebhook = verifyWebhook;
|
|
14
|
-
exports.VERSION = '0.
|
|
15
|
+
exports.VERSION = '0.2.0';
|
|
15
16
|
// ---------- errors ----------
|
|
16
17
|
class GuidingHandError extends Error {
|
|
17
18
|
status;
|
|
@@ -44,6 +45,7 @@ exports.PermissionDeniedError = PermissionDeniedError;
|
|
|
44
45
|
class NotFoundError extends GuidingHandError {
|
|
45
46
|
}
|
|
46
47
|
exports.NotFoundError = NotFoundError;
|
|
48
|
+
/** 409. From respond(): extra.code is 'already_answered' when someone answered first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
|
|
47
49
|
class ConflictError extends GuidingHandError {
|
|
48
50
|
}
|
|
49
51
|
exports.ConflictError = ConflictError;
|
|
@@ -62,7 +64,7 @@ exports.TimeoutError = TimeoutError;
|
|
|
62
64
|
class SessionExpiredError extends GuidingHandError {
|
|
63
65
|
}
|
|
64
66
|
exports.SessionExpiredError = SessionExpiredError;
|
|
65
|
-
/** A task waits on a question or approval and `run()` got no handler for it. */
|
|
67
|
+
/** A task waits on a question or approval and `run()` got no handler for it (a question the person at the computer can answer is left to them instead). */
|
|
66
68
|
class NeedsInputError extends GuidingHandError {
|
|
67
69
|
task;
|
|
68
70
|
constructor(task) { super(`The task is waiting for ${task.pending?.type === 'approval' ? 'an approval' : 'an answer'}; pass ${task.pending?.type === 'approval' ? 'onApproval' : 'onQuestion'} to run().`, 0, 'needs_input'); this.task = task; }
|
|
@@ -123,6 +125,9 @@ class GuidingHand {
|
|
|
123
125
|
}
|
|
124
126
|
if (res.ok)
|
|
125
127
|
return (raw ? new Uint8Array(await res.arrayBuffer()) : await res.json());
|
|
128
|
+
// A retried delete that finds it gone: the first attempt did it (its answer was lost on the way back).
|
|
129
|
+
if (method === 'DELETE' && attempt > 0 && res.status === 404)
|
|
130
|
+
return { object: path.split('/')[1]?.replace(/s$/, '') ?? 'object', deleted: true };
|
|
126
131
|
if (canRetry && attempt < this.maxRetries && (res.status === 429 || res.status >= 500)) {
|
|
127
132
|
const after = Number(res.headers.get('retry-after'));
|
|
128
133
|
await sleep(Number.isFinite(after) && after > 0 ? after * 1000 : 500 * 2 ** attempt);
|
|
@@ -209,14 +214,16 @@ class Tasks extends Resource {
|
|
|
209
214
|
return r.task;
|
|
210
215
|
}
|
|
211
216
|
}
|
|
212
|
-
/** Starts a task and sees it through: events to onEvent, questions to onQuestion, approvals to onApproval. Resolves with the finished task. */
|
|
217
|
+
/** Starts a task and sees it through: events to onEvent, questions to onQuestion (or the person at the computer), approvals to onApproval. Resolves with the finished task. */
|
|
213
218
|
async run(sessionId, { onEvent, onQuestion, onApproval, timeout, ...params }) {
|
|
214
219
|
let task = await this.create(sessionId, params);
|
|
215
220
|
const until = timeout ? Date.now() + timeout : Infinity;
|
|
216
221
|
let after = 0, answered = '';
|
|
217
222
|
while (!task.done) {
|
|
218
223
|
if (Date.now() > until) {
|
|
219
|
-
await this.stop(task.task_id).catch(() =>
|
|
224
|
+
const stopped = await this.stop(task.task_id).catch(() => null);
|
|
225
|
+
if (stopped && stopped.done && !stopped.interrupted)
|
|
226
|
+
return stopped; // it finished just as time ran out
|
|
220
227
|
throw new TimeoutError(`Task ${task.task_id} took longer than ${Math.round(timeout / 1000)} s; stopped it.`, 0, 'timeout');
|
|
221
228
|
}
|
|
222
229
|
const r = await this.events(task.task_id, { after, wait_ms: Math.min(25_000, Math.max(0, until - Date.now())) });
|
|
@@ -229,18 +236,32 @@ class Tasks extends Resource {
|
|
|
229
236
|
continue;
|
|
230
237
|
const id = p.type === 'question' ? p.question_id : p.approval_id;
|
|
231
238
|
if (id === answered)
|
|
232
|
-
continue; // answered already; the task is picking it up
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
239
|
+
continue; // answered already (or left to the person at the computer); the task is picking it up
|
|
240
|
+
try {
|
|
241
|
+
if (p.type === 'question') {
|
|
242
|
+
// Without onQuestion, or when it returns nothing, a question the person at the computer can answer on
|
|
243
|
+
// their screen is left to them: keep following.
|
|
244
|
+
if (!onQuestion && !p.customer_can_answer)
|
|
245
|
+
throw new NeedsInputError(task);
|
|
246
|
+
const reply = onQuestion ? await onQuestion(p, task) : null;
|
|
247
|
+
if (reply !== null && reply !== undefined)
|
|
248
|
+
await this.respond(task.task_id, { question_id: p.question_id, answer: String(reply) });
|
|
249
|
+
else if (!p.customer_can_answer)
|
|
250
|
+
throw new TypeError('onQuestion returned no answer, and the person at the computer can’t answer this question (customer_can_answer is false).');
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
if (!onApproval)
|
|
254
|
+
throw new NeedsInputError(task);
|
|
255
|
+
const d = await onApproval(p, task);
|
|
256
|
+
const decision = d && typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
|
|
257
|
+
await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
|
|
258
|
+
}
|
|
237
259
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
|
|
260
|
+
catch (e) {
|
|
261
|
+
// Answered somewhere else first (the person at the computer, the console, another process: extra.code
|
|
262
|
+
// 'already_answered') or the task ended meanwhile: keep following.
|
|
263
|
+
if (!(e instanceof ConflictError))
|
|
264
|
+
throw e;
|
|
244
265
|
}
|
|
245
266
|
answered = id;
|
|
246
267
|
}
|
|
@@ -250,7 +271,11 @@ class Tasks extends Resource {
|
|
|
250
271
|
// ---------- webhooks ----------
|
|
251
272
|
class WebhookEndpoint extends Resource {
|
|
252
273
|
retrieve() { return this.client.request('GET', '/webhook'); }
|
|
253
|
-
/**
|
|
274
|
+
/**
|
|
275
|
+
* Sets the endpoint. Only the fields you pass change: without url the endpoint stays, without events the
|
|
276
|
+
* filter stays, so { rotate_secret: true } alone makes a new secret. The signing secret is in the response
|
|
277
|
+
* when it's new (or rotated), never again.
|
|
278
|
+
*/
|
|
254
279
|
update(params) { return this.client.request('PUT', '/webhook', { body: params }); }
|
|
255
280
|
delete() { return this.client.request('PUT', '/webhook', { body: { url: null } }); }
|
|
256
281
|
}
|
|
@@ -267,8 +292,10 @@ async function verifyWebhook(payload, signatureHeader, secret, { tolerance = 300
|
|
|
267
292
|
if (Math.abs(Date.now() / 1000 - t) > tolerance)
|
|
268
293
|
throw fail('The webhook’s timestamp is too old.');
|
|
269
294
|
const body = typeof payload === 'string' ? payload : new TextDecoder().decode(payload);
|
|
270
|
-
|
|
271
|
-
const
|
|
295
|
+
// Node 18 has WebCrypto at node:crypto but not as a global; Node 20+, Deno, Bun and Workers have both.
|
|
296
|
+
const subtle = globalThis.crypto?.subtle ?? (await Promise.resolve(`${'node:crypto'}`).then(s => require(s))).webcrypto.subtle;
|
|
297
|
+
const key = await subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
298
|
+
const mac = new Uint8Array(await subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${body}`)));
|
|
272
299
|
const want = [...mac].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
273
300
|
if (want.length !== v1.length)
|
|
274
301
|
throw fail('The webhook’s signature doesn’t match.');
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.2.0";
|
|
2
2
|
export type Metadata = Record<string, string>;
|
|
3
3
|
export type Effort = 'low' | 'medium' | 'high' | null;
|
|
4
4
|
export type Agent = {
|
|
@@ -8,6 +8,12 @@ export type Agent = {
|
|
|
8
8
|
instructions: string;
|
|
9
9
|
effort: Effort;
|
|
10
10
|
greeting: string;
|
|
11
|
+
/** What the app calls the agent on the person's screen; '' is "GuidingHand". */
|
|
12
|
+
display_name: string;
|
|
13
|
+
/** The app shows the agent's thoughts and steps on the person's screen as it works. */
|
|
14
|
+
narration: boolean;
|
|
15
|
+
/** The person at the computer can answer the agent's questions in the app (the team still can; the first answer is used). Approvals always stay with the team. */
|
|
16
|
+
customer_answers: boolean;
|
|
11
17
|
is_default: boolean;
|
|
12
18
|
invite_url_template: string;
|
|
13
19
|
created_at: string | null;
|
|
@@ -38,11 +44,13 @@ export type Session = {
|
|
|
38
44
|
latest_task_id?: string | null;
|
|
39
45
|
};
|
|
40
46
|
export type TaskStatus = 'queued' | 'running' | 'waiting_for_user' | 'waiting_for_approval' | 'completed' | 'failed' | 'stopped';
|
|
47
|
+
/** customer_can_answer: the person at the computer can answer it on their screen too (the first answer is used). */
|
|
41
48
|
export type Question = {
|
|
42
49
|
type: 'question';
|
|
43
50
|
question_id: string;
|
|
44
51
|
question: string;
|
|
45
52
|
options: string[];
|
|
53
|
+
customer_can_answer: boolean;
|
|
46
54
|
};
|
|
47
55
|
export type Approval = {
|
|
48
56
|
type: 'approval';
|
|
@@ -51,6 +59,9 @@ export type Approval = {
|
|
|
51
59
|
risk: 'low' | 'medium' | 'high';
|
|
52
60
|
};
|
|
53
61
|
export type EventType = 'started' | 'progress' | 'thinking' | 'action' | 'message' | 'question' | 'answer' | 'approval_required' | 'approved' | 'denied' | 'completed' | 'error' | 'stopped';
|
|
62
|
+
/** Who answered a question: the person at the computer ('customer', in the app) or your team ('operator': the API or the console). */
|
|
63
|
+
export type AnsweredBy = 'customer' | 'operator';
|
|
64
|
+
/** data: e.g. { action } for 'action', { question_id, question, options } for 'question', { question_id, answered_by } for 'answer' (whose message is the answer). */
|
|
54
65
|
export type TaskEvent = {
|
|
55
66
|
cursor: number;
|
|
56
67
|
type: EventType;
|
|
@@ -96,7 +107,7 @@ export type Recording = {
|
|
|
96
107
|
task_id: string;
|
|
97
108
|
frames: Frame[];
|
|
98
109
|
};
|
|
99
|
-
export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | 'task.waiting_for_approval' | 'task.completed' | 'task.failed' | 'task.stopped';
|
|
110
|
+
export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | 'task.question_answered' | 'task.waiting_for_approval' | 'task.completed' | 'task.failed' | 'task.stopped';
|
|
100
111
|
export type Webhook = {
|
|
101
112
|
object: 'webhook';
|
|
102
113
|
url: string | null;
|
|
@@ -105,6 +116,12 @@ export type Webhook = {
|
|
|
105
116
|
secret?: string;
|
|
106
117
|
event_types: WebhookEventType[];
|
|
107
118
|
};
|
|
119
|
+
export type QuestionAnswer = {
|
|
120
|
+
question_id: string;
|
|
121
|
+
answer: string;
|
|
122
|
+
answered_by: AnsweredBy;
|
|
123
|
+
};
|
|
124
|
+
/** data.answer: for task.question_answered. */
|
|
108
125
|
export type WebhookEvent = {
|
|
109
126
|
id: string;
|
|
110
127
|
type: WebhookEventType;
|
|
@@ -113,6 +130,7 @@ export type WebhookEvent = {
|
|
|
113
130
|
data: {
|
|
114
131
|
task?: Task;
|
|
115
132
|
session?: Session;
|
|
133
|
+
answer?: QuestionAnswer;
|
|
116
134
|
};
|
|
117
135
|
};
|
|
118
136
|
export type Page<T> = {
|
|
@@ -141,6 +159,7 @@ export declare class PermissionDeniedError extends GuidingHandError {
|
|
|
141
159
|
}
|
|
142
160
|
export declare class NotFoundError extends GuidingHandError {
|
|
143
161
|
}
|
|
162
|
+
/** 409. From respond(): extra.code is 'already_answered' when someone answered first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
|
|
144
163
|
export declare class ConflictError extends GuidingHandError {
|
|
145
164
|
}
|
|
146
165
|
export declare class RateLimitError extends GuidingHandError {
|
|
@@ -153,7 +172,7 @@ export declare class TimeoutError extends GuidingHandError {
|
|
|
153
172
|
}
|
|
154
173
|
export declare class SessionExpiredError extends GuidingHandError {
|
|
155
174
|
}
|
|
156
|
-
/** A task waits on a question or approval and `run()` got no handler for it. */
|
|
175
|
+
/** A task waits on a question or approval and `run()` got no handler for it (a question the person at the computer can answer is left to them instead). */
|
|
157
176
|
export declare class NeedsInputError extends GuidingHandError {
|
|
158
177
|
task: Task;
|
|
159
178
|
constructor(task: Task);
|
|
@@ -205,6 +224,9 @@ export type AgentCreate = {
|
|
|
205
224
|
instructions?: string;
|
|
206
225
|
effort?: Effort;
|
|
207
226
|
greeting?: string;
|
|
227
|
+
display_name?: string;
|
|
228
|
+
narration?: boolean;
|
|
229
|
+
customer_answers?: boolean;
|
|
208
230
|
};
|
|
209
231
|
export type AgentUpdate = Partial<Omit<AgentCreate, 'agent_id'>>;
|
|
210
232
|
declare class Agents extends Resource {
|
|
@@ -264,8 +286,12 @@ export type Respond = {
|
|
|
264
286
|
export type RunOptions = TaskCreate & {
|
|
265
287
|
/** Every event, as it happens. */
|
|
266
288
|
onEvent?: (event: TaskEvent, task: Task) => void | Promise<void>;
|
|
267
|
-
/**
|
|
268
|
-
|
|
289
|
+
/**
|
|
290
|
+
* The agent asks something: return the answer. When the person at the computer can answer it on their screen
|
|
291
|
+
* (question.customer_can_answer), return null to leave it to them; without onQuestion, run() does that for
|
|
292
|
+
* every such question and throws NeedsInputError only for the others. If they answer first, yours is dropped.
|
|
293
|
+
*/
|
|
294
|
+
onQuestion?: (question: Question, task: Task) => string | null | undefined | Promise<string | null | undefined>;
|
|
269
295
|
/** The agent wants to do something consequential: return true (or 'approve') to let it, false (or 'deny', or { decision, note }) not to. */
|
|
270
296
|
onApproval?: (approval: Approval, task: Task) => boolean | 'approve' | 'deny' | {
|
|
271
297
|
decision: 'approve' | 'deny';
|
|
@@ -305,14 +331,18 @@ declare class Tasks extends Resource {
|
|
|
305
331
|
stream(taskId: string, { after }?: {
|
|
306
332
|
after?: number;
|
|
307
333
|
}): AsyncGenerator<TaskEvent, Task>;
|
|
308
|
-
/** Starts a task and sees it through: events to onEvent, questions to onQuestion, approvals to onApproval. Resolves with the finished task. */
|
|
334
|
+
/** Starts a task and sees it through: events to onEvent, questions to onQuestion (or the person at the computer), approvals to onApproval. Resolves with the finished task. */
|
|
309
335
|
run(sessionId: string, { onEvent, onQuestion, onApproval, timeout, ...params }: RunOptions): Promise<Task>;
|
|
310
336
|
}
|
|
311
337
|
declare class WebhookEndpoint extends Resource {
|
|
312
338
|
retrieve(): Promise<Webhook>;
|
|
313
|
-
/**
|
|
339
|
+
/**
|
|
340
|
+
* Sets the endpoint. Only the fields you pass change: without url the endpoint stays, without events the
|
|
341
|
+
* filter stays, so { rotate_secret: true } alone makes a new secret. The signing secret is in the response
|
|
342
|
+
* when it's new (or rotated), never again.
|
|
343
|
+
*/
|
|
314
344
|
update(params: {
|
|
315
|
-
url
|
|
345
|
+
url?: string;
|
|
316
346
|
events?: WebhookEventType[];
|
|
317
347
|
rotate_secret?: boolean;
|
|
318
348
|
}): Promise<Webhook>;
|
package/dist/esm/index.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// // send session.invite_url to the person at the computer, then:
|
|
8
8
|
// await gh.sessions.waitForConnection(session.session_id);
|
|
9
9
|
// const task = await gh.tasks.run(session.session_id, { prompt: 'Turn on Dark Mode', onQuestion: async (q) => 'Work', onApproval: async () => true });
|
|
10
|
-
|
|
10
|
+
// // (without onQuestion, questions the person at the computer can answer on their screen are left to them)
|
|
11
|
+
export const VERSION = '0.2.0';
|
|
11
12
|
// ---------- errors ----------
|
|
12
13
|
export class GuidingHandError extends Error {
|
|
13
14
|
status;
|
|
@@ -34,6 +35,7 @@ export class PermissionDeniedError extends GuidingHandError {
|
|
|
34
35
|
}
|
|
35
36
|
export class NotFoundError extends GuidingHandError {
|
|
36
37
|
}
|
|
38
|
+
/** 409. From respond(): extra.code is 'already_answered' when someone answered first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
|
|
37
39
|
export class ConflictError extends GuidingHandError {
|
|
38
40
|
}
|
|
39
41
|
export class RateLimitError extends GuidingHandError {
|
|
@@ -46,7 +48,7 @@ export class TimeoutError extends GuidingHandError {
|
|
|
46
48
|
}
|
|
47
49
|
export class SessionExpiredError extends GuidingHandError {
|
|
48
50
|
}
|
|
49
|
-
/** A task waits on a question or approval and `run()` got no handler for it. */
|
|
51
|
+
/** A task waits on a question or approval and `run()` got no handler for it (a question the person at the computer can answer is left to them instead). */
|
|
50
52
|
export class NeedsInputError extends GuidingHandError {
|
|
51
53
|
task;
|
|
52
54
|
constructor(task) { super(`The task is waiting for ${task.pending?.type === 'approval' ? 'an approval' : 'an answer'}; pass ${task.pending?.type === 'approval' ? 'onApproval' : 'onQuestion'} to run().`, 0, 'needs_input'); this.task = task; }
|
|
@@ -105,6 +107,9 @@ export class GuidingHand {
|
|
|
105
107
|
}
|
|
106
108
|
if (res.ok)
|
|
107
109
|
return (raw ? new Uint8Array(await res.arrayBuffer()) : await res.json());
|
|
110
|
+
// A retried delete that finds it gone: the first attempt did it (its answer was lost on the way back).
|
|
111
|
+
if (method === 'DELETE' && attempt > 0 && res.status === 404)
|
|
112
|
+
return { object: path.split('/')[1]?.replace(/s$/, '') ?? 'object', deleted: true };
|
|
108
113
|
if (canRetry && attempt < this.maxRetries && (res.status === 429 || res.status >= 500)) {
|
|
109
114
|
const after = Number(res.headers.get('retry-after'));
|
|
110
115
|
await sleep(Number.isFinite(after) && after > 0 ? after * 1000 : 500 * 2 ** attempt);
|
|
@@ -190,14 +195,16 @@ class Tasks extends Resource {
|
|
|
190
195
|
return r.task;
|
|
191
196
|
}
|
|
192
197
|
}
|
|
193
|
-
/** Starts a task and sees it through: events to onEvent, questions to onQuestion, approvals to onApproval. Resolves with the finished task. */
|
|
198
|
+
/** Starts a task and sees it through: events to onEvent, questions to onQuestion (or the person at the computer), approvals to onApproval. Resolves with the finished task. */
|
|
194
199
|
async run(sessionId, { onEvent, onQuestion, onApproval, timeout, ...params }) {
|
|
195
200
|
let task = await this.create(sessionId, params);
|
|
196
201
|
const until = timeout ? Date.now() + timeout : Infinity;
|
|
197
202
|
let after = 0, answered = '';
|
|
198
203
|
while (!task.done) {
|
|
199
204
|
if (Date.now() > until) {
|
|
200
|
-
await this.stop(task.task_id).catch(() =>
|
|
205
|
+
const stopped = await this.stop(task.task_id).catch(() => null);
|
|
206
|
+
if (stopped && stopped.done && !stopped.interrupted)
|
|
207
|
+
return stopped; // it finished just as time ran out
|
|
201
208
|
throw new TimeoutError(`Task ${task.task_id} took longer than ${Math.round(timeout / 1000)} s; stopped it.`, 0, 'timeout');
|
|
202
209
|
}
|
|
203
210
|
const r = await this.events(task.task_id, { after, wait_ms: Math.min(25_000, Math.max(0, until - Date.now())) });
|
|
@@ -210,18 +217,32 @@ class Tasks extends Resource {
|
|
|
210
217
|
continue;
|
|
211
218
|
const id = p.type === 'question' ? p.question_id : p.approval_id;
|
|
212
219
|
if (id === answered)
|
|
213
|
-
continue; // answered already; the task is picking it up
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
220
|
+
continue; // answered already (or left to the person at the computer); the task is picking it up
|
|
221
|
+
try {
|
|
222
|
+
if (p.type === 'question') {
|
|
223
|
+
// Without onQuestion, or when it returns nothing, a question the person at the computer can answer on
|
|
224
|
+
// their screen is left to them: keep following.
|
|
225
|
+
if (!onQuestion && !p.customer_can_answer)
|
|
226
|
+
throw new NeedsInputError(task);
|
|
227
|
+
const reply = onQuestion ? await onQuestion(p, task) : null;
|
|
228
|
+
if (reply !== null && reply !== undefined)
|
|
229
|
+
await this.respond(task.task_id, { question_id: p.question_id, answer: String(reply) });
|
|
230
|
+
else if (!p.customer_can_answer)
|
|
231
|
+
throw new TypeError('onQuestion returned no answer, and the person at the computer can’t answer this question (customer_can_answer is false).');
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
if (!onApproval)
|
|
235
|
+
throw new NeedsInputError(task);
|
|
236
|
+
const d = await onApproval(p, task);
|
|
237
|
+
const decision = d && typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
|
|
238
|
+
await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
|
|
239
|
+
}
|
|
218
240
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
|
|
241
|
+
catch (e) {
|
|
242
|
+
// Answered somewhere else first (the person at the computer, the console, another process: extra.code
|
|
243
|
+
// 'already_answered') or the task ended meanwhile: keep following.
|
|
244
|
+
if (!(e instanceof ConflictError))
|
|
245
|
+
throw e;
|
|
225
246
|
}
|
|
226
247
|
answered = id;
|
|
227
248
|
}
|
|
@@ -231,7 +252,11 @@ class Tasks extends Resource {
|
|
|
231
252
|
// ---------- webhooks ----------
|
|
232
253
|
class WebhookEndpoint extends Resource {
|
|
233
254
|
retrieve() { return this.client.request('GET', '/webhook'); }
|
|
234
|
-
/**
|
|
255
|
+
/**
|
|
256
|
+
* Sets the endpoint. Only the fields you pass change: without url the endpoint stays, without events the
|
|
257
|
+
* filter stays, so { rotate_secret: true } alone makes a new secret. The signing secret is in the response
|
|
258
|
+
* when it's new (or rotated), never again.
|
|
259
|
+
*/
|
|
235
260
|
update(params) { return this.client.request('PUT', '/webhook', { body: params }); }
|
|
236
261
|
delete() { return this.client.request('PUT', '/webhook', { body: { url: null } }); }
|
|
237
262
|
}
|
|
@@ -248,8 +273,10 @@ export async function verifyWebhook(payload, signatureHeader, secret, { toleranc
|
|
|
248
273
|
if (Math.abs(Date.now() / 1000 - t) > tolerance)
|
|
249
274
|
throw fail('The webhook’s timestamp is too old.');
|
|
250
275
|
const body = typeof payload === 'string' ? payload : new TextDecoder().decode(payload);
|
|
251
|
-
|
|
252
|
-
const
|
|
276
|
+
// Node 18 has WebCrypto at node:crypto but not as a global; Node 20+, Deno, Bun and Workers have both.
|
|
277
|
+
const subtle = globalThis.crypto?.subtle ?? (await import('node:crypto')).webcrypto.subtle;
|
|
278
|
+
const key = await subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
279
|
+
const mac = new Uint8Array(await subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${body}`)));
|
|
253
280
|
const want = [...mac].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
254
281
|
if (want.length !== v1.length)
|
|
255
282
|
throw fail('The webhook’s signature doesn’t match.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guidinghand",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "GuidingHand SDK: put an AI agent on your customer's computer with one link. Sessions, tasks, agents, recordings and webhooks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://guidinghand.ai",
|