@foir/sdk 0.29.0 → 0.30.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 +84 -0
- package/dist/ai/index.d.ts +190 -0
- package/dist/ai.js +566 -0
- package/dist/auth/auth.d.ts +15 -1
- package/dist/auth/code-flow.d.ts +1 -1
- package/dist/auth/grant-table.generated.d.ts +1 -1
- package/dist/auth/grants.d.ts +2 -1
- package/dist/auth/hosted.d.ts +3 -3
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/session-client.d.ts +1 -1
- package/dist/auth.js +13 -1
- package/dist/client/platform-client.d.ts +1 -1
- package/dist/data/agents.d.ts +104 -0
- package/dist/data/builtins.d.ts +182 -0
- package/dist/data/index.d.ts +5 -3
- package/dist/data/subjects.d.ts +45 -0
- package/dist/data/subscriptions.d.ts +38 -0
- package/dist/data.js +169 -2
- package/dist/placement.js +7 -1
- package/dist/react/FoirFeedback.d.ts +18 -1
- package/dist/react/SignIn.d.ts +2 -2
- package/dist/react/feedback-notes.d.ts +7 -0
- package/dist/react.js +261 -9
- package/dist/server/webhook-helpers.d.ts +3 -1
- package/dist/server.js +31 -4
- package/package.json +12 -3
package/dist/ai.js
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
// src/ai/index.ts
|
|
2
|
+
import { asSchema } from "ai";
|
|
3
|
+
|
|
4
|
+
// src/data/agents.ts
|
|
5
|
+
async function uploadAgentFile(foir, options) {
|
|
6
|
+
const agents = foir.ns.agents;
|
|
7
|
+
if (typeof agents?.createAgentAttachment !== "function") {
|
|
8
|
+
throw new Error("[foir] uploadAgentFile: this client sends no agent files. Its key runs none of the project's agents, or the project has released none.");
|
|
9
|
+
}
|
|
10
|
+
const { blob, mediaType, filename } = fileParts(options.file);
|
|
11
|
+
const up = await agents.createAgentAttachment({
|
|
12
|
+
agent: options.agent,
|
|
13
|
+
filename,
|
|
14
|
+
mediaType,
|
|
15
|
+
size: blob.size,
|
|
16
|
+
...options.visitorToken ? { visitorToken: options.visitorToken } : {}
|
|
17
|
+
});
|
|
18
|
+
const put = await (options.fetch ?? fetch)(up.uploadUrl, { method: "PUT", headers: { "content-type": mediaType }, body: blob });
|
|
19
|
+
if (!put.ok) throw new Error(`[foir] uploadAgentFile: ${filename} was not uploaded (${put.status}).`);
|
|
20
|
+
return up.attachment;
|
|
21
|
+
}
|
|
22
|
+
function fileParts(file) {
|
|
23
|
+
if (typeof Blob !== "undefined" && file instanceof Blob) {
|
|
24
|
+
const name = file.name;
|
|
25
|
+
return { blob: file, mediaType: file.type, filename: name || "file" };
|
|
26
|
+
}
|
|
27
|
+
const { data, mediaType, filename } = file;
|
|
28
|
+
const blob = data instanceof Blob ? data : new Blob([data], { type: mediaType });
|
|
29
|
+
return { blob, mediaType, filename: filename || "file" };
|
|
30
|
+
}
|
|
31
|
+
var VISITOR_KEY = "foir:agents:visitor";
|
|
32
|
+
function visitorToken(storage = browserStorage(), key = VISITOR_KEY) {
|
|
33
|
+
try {
|
|
34
|
+
const kept = storage?.getItem(key);
|
|
35
|
+
if (kept) return kept;
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
const made = randomSecret();
|
|
39
|
+
try {
|
|
40
|
+
storage?.setItem(key, made);
|
|
41
|
+
} catch {
|
|
42
|
+
}
|
|
43
|
+
return made;
|
|
44
|
+
}
|
|
45
|
+
function browserStorage() {
|
|
46
|
+
try {
|
|
47
|
+
return typeof localStorage === "undefined" ? null : localStorage;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function randomSecret() {
|
|
53
|
+
const bytes = new Uint8Array(24);
|
|
54
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
55
|
+
let out = "";
|
|
56
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/ai/index.ts
|
|
61
|
+
var TOOL_KINDS = /* @__PURE__ */ new Set(["VERB", "OPERATION", "MCP_TOOL", "AGENT", "PAGE_TOOL"]);
|
|
62
|
+
var STOPS = /* @__PURE__ */ new Set(["COMPLETED", "FAILED", "SKIPPED", "CANCELLED", "HELD"]);
|
|
63
|
+
var FoirRunChunks = class {
|
|
64
|
+
/** `runs` names the page's tools the transport runs itself. */
|
|
65
|
+
constructor(runId, runs = () => false) {
|
|
66
|
+
this.runId = runId;
|
|
67
|
+
this.runs = runs;
|
|
68
|
+
}
|
|
69
|
+
runId;
|
|
70
|
+
runs;
|
|
71
|
+
tools = /* @__PURE__ */ new Map();
|
|
72
|
+
/** The last event shown, to follow the run again from. */
|
|
73
|
+
after = "";
|
|
74
|
+
textId = null;
|
|
75
|
+
reasoningId = null;
|
|
76
|
+
opened = false;
|
|
77
|
+
/** The run stopped for this stream's followers: it ended, or holds an act. */
|
|
78
|
+
stopped = false;
|
|
79
|
+
/** Calls to the page's tools waiting for the app's `addToolOutput`. */
|
|
80
|
+
pageCalls = /* @__PURE__ */ new Set();
|
|
81
|
+
/** Calls to the page's tools the transport runs and answers itself, until the run hears them. */
|
|
82
|
+
answering = /* @__PURE__ */ new Set();
|
|
83
|
+
/** Calls the transport is to run: taken with `takeCalls`. */
|
|
84
|
+
toRun = [];
|
|
85
|
+
/** Acts held for a decision, by anyone. */
|
|
86
|
+
held = /* @__PURE__ */ new Set();
|
|
87
|
+
/** The calls to the page's tools that arrived for the transport to run. */
|
|
88
|
+
takeCalls() {
|
|
89
|
+
const out = this.toRun;
|
|
90
|
+
this.toRun = [];
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/** Starts a new stream of the same run, as after a decision. */
|
|
94
|
+
resume() {
|
|
95
|
+
this.opened = false;
|
|
96
|
+
this.stopped = false;
|
|
97
|
+
this.textId = null;
|
|
98
|
+
this.reasoningId = null;
|
|
99
|
+
}
|
|
100
|
+
open(out, fresh) {
|
|
101
|
+
if (this.opened) return;
|
|
102
|
+
this.opened = true;
|
|
103
|
+
if (fresh) out.push({ type: "start" });
|
|
104
|
+
out.push({ type: "start-step" });
|
|
105
|
+
}
|
|
106
|
+
closeText(out) {
|
|
107
|
+
if (this.textId) out.push({ type: "text-end", id: this.textId });
|
|
108
|
+
this.textId = null;
|
|
109
|
+
}
|
|
110
|
+
closeReasoning(out) {
|
|
111
|
+
if (this.reasoningId) out.push({ type: "reasoning-end", id: this.reasoningId });
|
|
112
|
+
this.reasoningId = null;
|
|
113
|
+
}
|
|
114
|
+
/** The chunks an event makes. `fresh` is a stream that starts a new message. */
|
|
115
|
+
of(event, fresh = true) {
|
|
116
|
+
const out = [];
|
|
117
|
+
if (event.seq) this.after = event.seq;
|
|
118
|
+
switch (event.kind) {
|
|
119
|
+
case "TEXT": {
|
|
120
|
+
if (!event.text) break;
|
|
121
|
+
this.open(out, fresh);
|
|
122
|
+
this.closeReasoning(out);
|
|
123
|
+
if (!this.textId) {
|
|
124
|
+
this.textId = `${this.runId}:t:${event.seq}`;
|
|
125
|
+
out.push({ type: "text-start", id: this.textId });
|
|
126
|
+
}
|
|
127
|
+
out.push({ type: "text-delta", id: this.textId, delta: event.text });
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case "REASONING": {
|
|
131
|
+
if (!event.text) break;
|
|
132
|
+
this.open(out, fresh);
|
|
133
|
+
if (!this.reasoningId) {
|
|
134
|
+
this.reasoningId = `${this.runId}:r:${event.seq}`;
|
|
135
|
+
out.push({ type: "reasoning-start", id: this.reasoningId });
|
|
136
|
+
}
|
|
137
|
+
out.push({ type: "reasoning-delta", id: this.reasoningId, delta: event.text });
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case "NODE": {
|
|
141
|
+
const node = event.node;
|
|
142
|
+
if (!node || !TOOL_KINDS.has(node.kind) || node.id === this.runId) break;
|
|
143
|
+
this.open(out, fresh);
|
|
144
|
+
this.closeText(out);
|
|
145
|
+
this.closeReasoning(out);
|
|
146
|
+
out.push(...this.toolChunks(node));
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "DATA": {
|
|
150
|
+
if (!event.dataName) break;
|
|
151
|
+
this.open(out, fresh);
|
|
152
|
+
out.push({ type: `data-${event.dataName}`, id: `${this.runId}:d:${event.seq}`, data: event.data ?? null });
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "STATUS": {
|
|
156
|
+
if (!event.status || !STOPS.has(event.status)) break;
|
|
157
|
+
if (event.status === "HELD" && this.answering.size > 0 && this.pageCalls.size === 0 && this.held.size === 0) break;
|
|
158
|
+
this.open(out, fresh);
|
|
159
|
+
this.closeText(out);
|
|
160
|
+
this.closeReasoning(out);
|
|
161
|
+
if (event.status === "FAILED") {
|
|
162
|
+
out.push({ type: "error", errorText: event.error || "The answer could not be finished." });
|
|
163
|
+
}
|
|
164
|
+
if (event.status === "SKIPPED" && event.answer && this.tools.size === 0) {
|
|
165
|
+
const id = `${this.runId}:refusal`;
|
|
166
|
+
out.push({ type: "text-start", id }, { type: "text-delta", id, delta: event.answer }, { type: "text-end", id });
|
|
167
|
+
}
|
|
168
|
+
out.push({ type: "finish-step" }, { type: "finish" });
|
|
169
|
+
this.stopped = true;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
default:
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
toolChunks(node) {
|
|
178
|
+
const out = [];
|
|
179
|
+
const shown = this.tools.get(node.id);
|
|
180
|
+
if (shown === node.status) return out;
|
|
181
|
+
if (shown === void 0) {
|
|
182
|
+
const input = node.input?.args ?? node.input ?? {};
|
|
183
|
+
out.push({ type: "tool-input-available", toolCallId: node.id, toolName: node.key, input, dynamic: true });
|
|
184
|
+
}
|
|
185
|
+
this.tools.set(node.id, node.status);
|
|
186
|
+
if (node.kind === "PAGE_TOOL") return this.pageToolChunks(node, out);
|
|
187
|
+
if (node.status === "HELD" && node.approval) this.held.add(node.id);
|
|
188
|
+
else this.held.delete(node.id);
|
|
189
|
+
switch (node.status) {
|
|
190
|
+
case "COMPLETED":
|
|
191
|
+
out.push({ type: "tool-output-available", toolCallId: node.id, output: node.result ?? {}, dynamic: true });
|
|
192
|
+
break;
|
|
193
|
+
case "HELD":
|
|
194
|
+
if (node.approval?.approver === "CALLER" && node.approval.decision === "PENDING") {
|
|
195
|
+
out.push({ type: "tool-approval-request", approvalId: node.id, toolCallId: node.id });
|
|
196
|
+
} else if (node.approval) {
|
|
197
|
+
out.push({
|
|
198
|
+
type: "tool-output-available",
|
|
199
|
+
toolCallId: node.id,
|
|
200
|
+
output: { status: "held", approver: node.approval.approver, summary: node.approval.summary },
|
|
201
|
+
preliminary: true,
|
|
202
|
+
dynamic: true
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
case "SKIPPED":
|
|
207
|
+
if (node.approval?.decision === "DENIED") out.push({ type: "tool-output-denied", toolCallId: node.id });
|
|
208
|
+
else out.push({ type: "tool-output-error", toolCallId: node.id, errorText: node.error || "Refused.", dynamic: true });
|
|
209
|
+
break;
|
|
210
|
+
case "FAILED":
|
|
211
|
+
case "CANCELLED":
|
|
212
|
+
out.push({ type: "tool-output-error", toolCallId: node.id, errorText: node.error || "Refused.", dynamic: true });
|
|
213
|
+
break;
|
|
214
|
+
default:
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* A call to one of the page's tools. The transport runs one it has an
|
|
221
|
+
* `execute` for and shows what it answered; any other waits for the app's
|
|
222
|
+
* `addToolOutput`. One the page did not answer in time is an error.
|
|
223
|
+
*/
|
|
224
|
+
pageToolChunks(node, out) {
|
|
225
|
+
const ours = this.runs(node.key);
|
|
226
|
+
if (node.status === "HELD") {
|
|
227
|
+
if (ours) {
|
|
228
|
+
this.answering.add(node.id);
|
|
229
|
+
this.toRun.push(node);
|
|
230
|
+
} else {
|
|
231
|
+
this.pageCalls.add(node.id);
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
this.pageCalls.delete(node.id);
|
|
236
|
+
const answered = this.answering.delete(node.id);
|
|
237
|
+
if (node.status === "COMPLETED" && answered) {
|
|
238
|
+
const result = node.result;
|
|
239
|
+
out.push(
|
|
240
|
+
{ type: "tool-output-available", toolCallId: node.id, output: result?.result ?? null, dynamic: true },
|
|
241
|
+
{ type: "finish-step" },
|
|
242
|
+
{ type: "start-step" }
|
|
243
|
+
);
|
|
244
|
+
} else if (node.status === "FAILED" || node.status === "CANCELLED" || node.status === "SKIPPED") {
|
|
245
|
+
out.push({ type: "tool-output-error", toolCallId: node.id, errorText: node.error || "The page did not answer.", dynamic: true });
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
async function pageToolInput(tool, raw) {
|
|
251
|
+
if (!tool.inputSchema) return raw ?? {};
|
|
252
|
+
const checked = await asSchema(tool.inputSchema).validate?.(raw);
|
|
253
|
+
if (!checked) return raw;
|
|
254
|
+
if (!checked.success) throw checked.error;
|
|
255
|
+
return checked.value;
|
|
256
|
+
}
|
|
257
|
+
async function pageToolOffers(tools) {
|
|
258
|
+
return Promise.all(
|
|
259
|
+
Object.entries(tools).map(async ([name, tool]) => {
|
|
260
|
+
const offer = { name };
|
|
261
|
+
if (tool.description) offer.description = tool.description;
|
|
262
|
+
if (tool.inputSchema) offer.inputSchema = await asSchema(tool.inputSchema).jsonSchema;
|
|
263
|
+
return offer;
|
|
264
|
+
})
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
function asAnswer(value) {
|
|
268
|
+
if (value === void 0) return null;
|
|
269
|
+
try {
|
|
270
|
+
return JSON.parse(JSON.stringify(value));
|
|
271
|
+
} catch {
|
|
272
|
+
return { error: "The tool answered something that is not JSON." };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function lastUserText(messages) {
|
|
276
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
277
|
+
const m = messages[i];
|
|
278
|
+
if (m?.role !== "user") continue;
|
|
279
|
+
const text = m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n");
|
|
280
|
+
return { id: m.id, text };
|
|
281
|
+
}
|
|
282
|
+
return { id: "", text: "" };
|
|
283
|
+
}
|
|
284
|
+
function lastUserFiles(messages) {
|
|
285
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
286
|
+
const m = messages[i];
|
|
287
|
+
if (m?.role !== "user") continue;
|
|
288
|
+
return m.parts.filter((p) => p.type === "file").map((p) => ({ url: p.url, mediaType: p.mediaType, filename: p.filename }));
|
|
289
|
+
}
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
function lastUserTags(messages) {
|
|
293
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
294
|
+
const m = messages[i];
|
|
295
|
+
if (m?.role !== "user") continue;
|
|
296
|
+
const out = [];
|
|
297
|
+
for (const part of m.parts) {
|
|
298
|
+
const p = part;
|
|
299
|
+
if (p.type !== "data-tag" || !p.data) continue;
|
|
300
|
+
const str = (k) => typeof p.data?.[k] === "string" ? p.data[k] : void 0;
|
|
301
|
+
const kind = str("kind")?.toUpperCase();
|
|
302
|
+
const id = str("id");
|
|
303
|
+
if (!kind || !id) continue;
|
|
304
|
+
out.push({ kind, id, model: str("model"), field: str("field"), label: str("label") });
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
async function blobOf(url, mediaType) {
|
|
311
|
+
const resp = await fetch(url);
|
|
312
|
+
if (!resp.ok) throw new Error(`A file could not be read (${resp.status}).`);
|
|
313
|
+
const blob = await resp.blob();
|
|
314
|
+
return blob.type ? blob : new Blob([blob], { type: mediaType });
|
|
315
|
+
}
|
|
316
|
+
function decisionsIn(messages, sent) {
|
|
317
|
+
const last = messages[messages.length - 1];
|
|
318
|
+
if (last?.role !== "assistant") return [];
|
|
319
|
+
const out = [];
|
|
320
|
+
for (const part of last.parts) {
|
|
321
|
+
const p = part;
|
|
322
|
+
if (!(p.type === "dynamic-tool" || p.type.startsWith("tool-")) || p.state !== "approval-responded" || !p.approval) continue;
|
|
323
|
+
if (sent.has(p.approval.id)) continue;
|
|
324
|
+
out.push({ id: p.approval.id, approve: p.approval.approved === true, reason: p.approval.reason });
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
function pageAnswersIn(messages, waiting) {
|
|
329
|
+
const last = messages[messages.length - 1];
|
|
330
|
+
if (last?.role !== "assistant") return [];
|
|
331
|
+
const out = [];
|
|
332
|
+
for (const part of last.parts) {
|
|
333
|
+
const p = part;
|
|
334
|
+
if (p.type !== "dynamic-tool" || p.state !== "output-available" || !p.toolCallId || !waiting.has(p.toolCallId)) continue;
|
|
335
|
+
out.push({ id: p.toolCallId, output: p.output ?? null });
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
function agentsOf(client, caller) {
|
|
340
|
+
const found = client.ns.agents;
|
|
341
|
+
if (!found) {
|
|
342
|
+
const refuse = () => {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`[foir] ${caller}: this client offers no agents. Its key runs none of the project's agents (agents:execute), or the project has released none.`
|
|
345
|
+
);
|
|
346
|
+
};
|
|
347
|
+
return {
|
|
348
|
+
startAgentRun: refuse,
|
|
349
|
+
onRun: refuse,
|
|
350
|
+
decideAgentApproval: refuse,
|
|
351
|
+
answerPageTool: refuse,
|
|
352
|
+
agentThread: refuse,
|
|
353
|
+
createAgentAttachment: refuse
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
return found;
|
|
357
|
+
}
|
|
358
|
+
function foirChatTransport(options) {
|
|
359
|
+
const { client, agent } = options;
|
|
360
|
+
const pageTools = options.pageTools ?? {};
|
|
361
|
+
const agents = agentsOf(client, "foirChatTransport");
|
|
362
|
+
let offers = null;
|
|
363
|
+
const offered = () => offers ??= pageToolOffers(pageTools);
|
|
364
|
+
const runsItself = (name) => typeof pageTools[name]?.execute === "function";
|
|
365
|
+
const visitor = options.visitorToken === void 0 ? visitorToken() : options.visitorToken ?? void 0;
|
|
366
|
+
const store = options.threads === void 0 ? browserStorage2() : options.threads;
|
|
367
|
+
const pageThreads = /* @__PURE__ */ new Map();
|
|
368
|
+
const runs = /* @__PURE__ */ new Map();
|
|
369
|
+
const lastRun = /* @__PURE__ */ new Map();
|
|
370
|
+
const decided = /* @__PURE__ */ new Set();
|
|
371
|
+
const uploaded = /* @__PURE__ */ new Map();
|
|
372
|
+
const threadKey = (chatId) => `foir:agents:thread:${agent}:${chatId}`;
|
|
373
|
+
const threadOf = (chatId) => {
|
|
374
|
+
const kept = pageThreads.get(chatId);
|
|
375
|
+
if (kept) return kept;
|
|
376
|
+
try {
|
|
377
|
+
return store?.getItem(threadKey(chatId)) ?? null;
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
const keepThread = (chatId, threadId) => {
|
|
383
|
+
pageThreads.set(chatId, threadId);
|
|
384
|
+
try {
|
|
385
|
+
store?.setItem(threadKey(chatId), threadId);
|
|
386
|
+
} catch {
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
const chunksOf = (runId) => {
|
|
390
|
+
let c = runs.get(runId);
|
|
391
|
+
if (!c) {
|
|
392
|
+
c = new FoirRunChunks(runId, runsItself);
|
|
393
|
+
runs.set(runId, c);
|
|
394
|
+
}
|
|
395
|
+
return c;
|
|
396
|
+
};
|
|
397
|
+
const answerCall = async (node, signal) => {
|
|
398
|
+
const tool = pageTools[node.key];
|
|
399
|
+
let output;
|
|
400
|
+
try {
|
|
401
|
+
const raw = node.input?.args ?? node.input;
|
|
402
|
+
const input = await pageToolInput(tool, raw);
|
|
403
|
+
output = asAnswer(await tool.execute(input, { toolCallId: node.id, messages: [], abortSignal: signal }));
|
|
404
|
+
} catch (err) {
|
|
405
|
+
output = { error: err instanceof Error ? err.message : String(err) };
|
|
406
|
+
}
|
|
407
|
+
await agents.answerPageTool({ id: node.id, output, visitorToken: visitor });
|
|
408
|
+
};
|
|
409
|
+
const follow = (runId, fresh, signal) => {
|
|
410
|
+
const chunks = chunksOf(runId);
|
|
411
|
+
chunks.resume();
|
|
412
|
+
let sub = null;
|
|
413
|
+
let closed = false;
|
|
414
|
+
return new ReadableStream({
|
|
415
|
+
start(controller) {
|
|
416
|
+
const close = () => {
|
|
417
|
+
if (closed) return;
|
|
418
|
+
closed = true;
|
|
419
|
+
sub?.unsubscribe();
|
|
420
|
+
controller.close();
|
|
421
|
+
};
|
|
422
|
+
sub = agents.onRun({
|
|
423
|
+
id: runId,
|
|
424
|
+
after: chunks.after || void 0,
|
|
425
|
+
visitorToken: visitor,
|
|
426
|
+
onData: (event) => {
|
|
427
|
+
if (closed) return;
|
|
428
|
+
for (const c of chunks.of(event, fresh)) controller.enqueue(c);
|
|
429
|
+
for (const call of chunks.takeCalls()) {
|
|
430
|
+
answerCall(call, signal).catch((err) => {
|
|
431
|
+
chunks.answering.delete(call.id);
|
|
432
|
+
if (closed) return;
|
|
433
|
+
controller.enqueue({ type: "error", errorText: err instanceof Error ? err.message : String(err) });
|
|
434
|
+
close();
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
if (chunks.stopped) close();
|
|
438
|
+
},
|
|
439
|
+
onError: (err) => {
|
|
440
|
+
if (closed) return;
|
|
441
|
+
controller.enqueue({ type: "error", errorText: err instanceof Error ? err.message : String(err) });
|
|
442
|
+
close();
|
|
443
|
+
},
|
|
444
|
+
onComplete: close
|
|
445
|
+
});
|
|
446
|
+
signal?.addEventListener("abort", close, { once: true });
|
|
447
|
+
},
|
|
448
|
+
// The reader went away: stop following, and write nothing more to it.
|
|
449
|
+
cancel() {
|
|
450
|
+
closed = true;
|
|
451
|
+
sub?.unsubscribe();
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
};
|
|
455
|
+
return {
|
|
456
|
+
async sendMessages({ chatId, messages, abortSignal }) {
|
|
457
|
+
const decisions = decisionsIn(messages, decided);
|
|
458
|
+
const waiting = /* @__PURE__ */ new Set();
|
|
459
|
+
for (const c of runs.values()) for (const id2 of c.pageCalls) waiting.add(id2);
|
|
460
|
+
const answers = pageAnswersIn(messages, waiting);
|
|
461
|
+
if (decisions.length > 0 || answers.length > 0) {
|
|
462
|
+
for (const d of decisions) {
|
|
463
|
+
await agents.decideAgentApproval(d);
|
|
464
|
+
decided.add(d.id);
|
|
465
|
+
}
|
|
466
|
+
for (const a of answers) {
|
|
467
|
+
await agents.answerPageTool({ id: a.id, output: a.output, visitorToken: visitor });
|
|
468
|
+
for (const c of runs.values()) c.pageCalls.delete(a.id);
|
|
469
|
+
}
|
|
470
|
+
let runId = lastRun.get(chatId);
|
|
471
|
+
if (!runId) {
|
|
472
|
+
const threadId = threadOf(chatId);
|
|
473
|
+
const thread = threadId ? await agents.agentThread({ id: threadId, visitorToken: visitor }) : null;
|
|
474
|
+
runId = thread?.runningRunId ?? void 0;
|
|
475
|
+
}
|
|
476
|
+
if (!runId) throw new Error("The run these decisions were for could not be found.");
|
|
477
|
+
return follow(runId, false, abortSignal);
|
|
478
|
+
}
|
|
479
|
+
const { id, text } = lastUserText(messages);
|
|
480
|
+
let attachmentIds = id ? uploaded.get(id) : void 0;
|
|
481
|
+
if (!attachmentIds) {
|
|
482
|
+
const files = lastUserFiles(messages);
|
|
483
|
+
attachmentIds = [];
|
|
484
|
+
for (const f of files) {
|
|
485
|
+
const blob = await blobOf(f.url, f.mediaType);
|
|
486
|
+
const sent = await uploadAgentFile(client, {
|
|
487
|
+
agent,
|
|
488
|
+
file: { data: blob, mediaType: f.mediaType, filename: f.filename },
|
|
489
|
+
visitorToken: visitor
|
|
490
|
+
});
|
|
491
|
+
attachmentIds.push(sent.id);
|
|
492
|
+
}
|
|
493
|
+
if (id) uploaded.set(id, attachmentIds);
|
|
494
|
+
}
|
|
495
|
+
const tags = lastUserTags(messages);
|
|
496
|
+
const tools = await offered();
|
|
497
|
+
const run = await agents.startAgentRun({
|
|
498
|
+
agent,
|
|
499
|
+
message: text,
|
|
500
|
+
threadId: threadOf(chatId) ?? void 0,
|
|
501
|
+
idempotencyKey: id || void 0,
|
|
502
|
+
visitorToken: visitor,
|
|
503
|
+
...attachmentIds.length > 0 ? { attachmentIds } : {},
|
|
504
|
+
...tags.length > 0 ? { tags } : {},
|
|
505
|
+
...tools.length > 0 ? { pageTools: tools } : {}
|
|
506
|
+
});
|
|
507
|
+
if (run.threadId) keepThread(chatId, run.threadId);
|
|
508
|
+
lastRun.set(chatId, run.id);
|
|
509
|
+
if (STOPS.has(run.status) && run.status !== "HELD") {
|
|
510
|
+
const answer = run.answer || run.error || "";
|
|
511
|
+
const textId = `${run.id}:answer`;
|
|
512
|
+
return new ReadableStream({
|
|
513
|
+
start(controller) {
|
|
514
|
+
controller.enqueue({ type: "start" });
|
|
515
|
+
if (answer) {
|
|
516
|
+
controller.enqueue({ type: "text-start", id: textId });
|
|
517
|
+
controller.enqueue({ type: "text-delta", id: textId, delta: answer });
|
|
518
|
+
controller.enqueue({ type: "text-end", id: textId });
|
|
519
|
+
}
|
|
520
|
+
controller.enqueue({ type: "finish" });
|
|
521
|
+
controller.close();
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return follow(run.id, true, abortSignal);
|
|
526
|
+
},
|
|
527
|
+
async reconnectToStream({ chatId }) {
|
|
528
|
+
const threadId = threadOf(chatId);
|
|
529
|
+
if (!threadId) return null;
|
|
530
|
+
const thread = await agents.agentThread({ id: threadId, visitorToken: visitor });
|
|
531
|
+
if (!thread?.runningRunId) return null;
|
|
532
|
+
lastRun.set(chatId, thread.runningRunId);
|
|
533
|
+
return follow(thread.runningRunId, true);
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
async function foirChatMessages(client, threadId, options = {}) {
|
|
538
|
+
const visitor = options.visitorToken === void 0 ? visitorToken() : options.visitorToken ?? void 0;
|
|
539
|
+
const thread = await agentsOf(client, "foirChatMessages").agentThread({ id: threadId, visitorToken: visitor });
|
|
540
|
+
return (thread?.messages ?? []).map((m) => ({
|
|
541
|
+
id: m.id,
|
|
542
|
+
role: m.role === "USER" ? "user" : "assistant",
|
|
543
|
+
parts: (m.parts ?? []).map(asUIPart)
|
|
544
|
+
}));
|
|
545
|
+
}
|
|
546
|
+
function asUIPart(p) {
|
|
547
|
+
if (p.type !== "file") return p;
|
|
548
|
+
return {
|
|
549
|
+
type: "file",
|
|
550
|
+
mediaType: String(p.mediaType ?? ""),
|
|
551
|
+
url: String(p.url ?? ""),
|
|
552
|
+
...typeof p.filename === "string" ? { filename: p.filename } : {}
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
function browserStorage2() {
|
|
556
|
+
try {
|
|
557
|
+
return typeof localStorage === "undefined" ? null : localStorage;
|
|
558
|
+
} catch {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
export {
|
|
563
|
+
FoirRunChunks,
|
|
564
|
+
foirChatMessages,
|
|
565
|
+
foirChatTransport
|
|
566
|
+
};
|
package/dist/auth/auth.d.ts
CHANGED
|
@@ -199,7 +199,11 @@ export declare function logout(env: FoirEnv, options?: AuthOpOptions): Promise<b
|
|
|
199
199
|
* Irreversible. `password` is required for an account that holds one. An
|
|
200
200
|
* account with none sets `confirm`, on a sign-in made in the last ten minutes:
|
|
201
201
|
* an older one is refused, and the user signs in again. The server enforces
|
|
202
|
-
* the re-auth gate and fails closed.
|
|
202
|
+
* the re-auth gate and fails closed. The answer counts the records and files
|
|
203
|
+
* the user owns, which are destroyed just after it.
|
|
204
|
+
*
|
|
205
|
+
* This does NOT clear local session state: {@link deleteMyAccountAndSignOut}
|
|
206
|
+
* is the pairing that does both.
|
|
203
207
|
*/
|
|
204
208
|
export declare function deleteMyAccount(env: FoirEnv, vars: {
|
|
205
209
|
confirm: boolean;
|
|
@@ -472,6 +476,16 @@ export declare function resetPasswordAndSignIn(adapter: SessionAdapter, env: Foi
|
|
|
472
476
|
token: string;
|
|
473
477
|
newPassword: string;
|
|
474
478
|
}): Promise<FoirUser>;
|
|
479
|
+
/**
|
|
480
|
+
* Delete the signed-in user's account with the session's token, then clear the
|
|
481
|
+
* session, which the deletion ended server-side. A refusal (a wrong password,
|
|
482
|
+
* a sign-in too old to confirm with) leaves the session as it was, since the
|
|
483
|
+
* account is not deleted.
|
|
484
|
+
*/
|
|
485
|
+
export declare function deleteMyAccountAndSignOut(adapter: SessionAdapter, env: FoirEnv, vars: {
|
|
486
|
+
confirm: boolean;
|
|
487
|
+
password?: string;
|
|
488
|
+
}): Promise<DeleteAccountResult>;
|
|
475
489
|
/**
|
|
476
490
|
* End the session on both sides: revoke it server-side, then clear local state.
|
|
477
491
|
*
|
package/dist/auth/code-flow.d.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
/** Where the app signs people in. */
|
|
23
23
|
export interface CodeFlowConfig {
|
|
24
24
|
/**
|
|
25
|
-
* The issuer: Foir's own (`https://api.foir.
|
|
25
|
+
* The issuer: Foir's own (`https://api.foir.dev`) or a project's login
|
|
26
26
|
* domain (`https://login.example.com`), which is the issuer of the people
|
|
27
27
|
* who sign in through it.
|
|
28
28
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type GrantReach = 'own' | 'named' | 'all';
|
|
2
|
-
export type GrantTarget = 'none' | 'model' | 'operation';
|
|
2
|
+
export type GrantTarget = 'none' | 'model' | 'operation' | 'agent' | 'agent_tool';
|
|
3
3
|
/**
|
|
4
4
|
* One permission: its name, what a grant of it may name, the reaches it may be
|
|
5
5
|
* granted at (none: only its bare form exists), and the grants a holder of it
|
package/dist/auth/grants.d.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A grant is written `<resource>:<verb>[.<reach>][:<target>]`. The reach is
|
|
5
5
|
* `named` or `all`, and an unwritten reach means the holder's own rows. The
|
|
6
|
-
* target narrows it to one model or one
|
|
6
|
+
* target narrows it to one model, one operation, one agent, or one tool of a
|
|
7
|
+
* remote server registered on an agent (`<agent>.<server>.<tool>`).
|
|
7
8
|
*
|
|
8
9
|
* This is the platform's rule, answered from the platform's own declarations
|
|
9
10
|
* (the table is written from the contract when this package is built) and held
|
package/dist/auth/hosted.d.ts
CHANGED
|
@@ -152,7 +152,7 @@ export type HostedSessionResult =
|
|
|
152
152
|
*/
|
|
153
153
|
export declare function fetchHostedSession(request: Request, config: HostedLoginConfig): Promise<HostedSessionResult>;
|
|
154
154
|
/**
|
|
155
|
-
* What
|
|
155
|
+
* What hosted sign-in said beside the code when it sent someone back to your
|
|
156
156
|
* app. A sign-in it refused carries a stable code (`foir_error`); one that
|
|
157
157
|
* connected a provider to the person's account on the way in names the
|
|
158
158
|
* provider (`foir_connected`). Each comes with a sentence your app can show as
|
|
@@ -169,9 +169,9 @@ export type HostedSignInNotice = {
|
|
|
169
169
|
message: string;
|
|
170
170
|
};
|
|
171
171
|
/**
|
|
172
|
-
* Reads the notice off the address
|
|
172
|
+
* Reads the notice off the address hosted sign-in sent the browser back to:
|
|
173
173
|
* your callback URL, its search string, or its parameters. `names` maps a
|
|
174
174
|
* provider's key to the name to show (from `getAuthProviders`); without it the
|
|
175
|
-
* key is shown. Null when
|
|
175
|
+
* key is shown. Null when hosted sign-in said nothing.
|
|
176
176
|
*/
|
|
177
177
|
export declare function hostedSignInNoticeOf(location: string | URL | URLSearchParams, names?: Record<string, string>): HostedSignInNotice | null;
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the caller's own manifest advertises, through the same op transport
|
|
11
11
|
* `@foir/sdk/data` uses, so `graphql` is not on this module's path at all.
|
|
12
12
|
*/
|
|
13
|
-
export { login, register, requestOTP, loginWithOTP, refreshTokens, loginWithProvider, providerCallback, providerVerifyOTP, providerSignInRefusalOf, signInRefusalOf, confirmProviderLink, resendProviderLinkCode, getAuthProviders, getAuthConfig, getDefaultAuthProvider, signupContext, joinWaitlist, requestPasswordReset, resetPassword, updatePassword, currentUser, myProviderToken, logout, deleteMyAccount, leaveMembership, updateMyProfile, myAuthorizedApps, revokeAuthorizedApp, mySessions, endMySession, myAddresses, addMyAddress, proveMyAddress, makeMyAddressPrimary, removeMyAddress, exportMyData, signOutEverywhere, createPersonalAccessToken, mySignInMethods, connectSignInMethod, disconnectSignInMethod, myFactors, beginStepUp, completeStepUp, beginFactorEnrollment, completeFactorEnrollment, removeFactor, renameFactor, makeRecoveryCodes, myOrganizations, myOrganizationChoices, registerOrganization, saveSession, clearSession, getSessionToken, getSessionUser, signInWithPassword, signUpWithPassword, signInWithOTP, resetPasswordAndSignIn, refreshSession, refreshSessionOnce, signOut, authedRequest, authedOp, requireAuth, } from './auth';
|
|
13
|
+
export { login, register, requestOTP, loginWithOTP, refreshTokens, loginWithProvider, providerCallback, providerVerifyOTP, providerSignInRefusalOf, signInRefusalOf, confirmProviderLink, resendProviderLinkCode, getAuthProviders, getAuthConfig, getDefaultAuthProvider, signupContext, joinWaitlist, requestPasswordReset, resetPassword, updatePassword, currentUser, myProviderToken, logout, deleteMyAccount, deleteMyAccountAndSignOut, leaveMembership, updateMyProfile, myAuthorizedApps, revokeAuthorizedApp, mySessions, endMySession, myAddresses, addMyAddress, proveMyAddress, makeMyAddressPrimary, removeMyAddress, exportMyData, signOutEverywhere, createPersonalAccessToken, mySignInMethods, connectSignInMethod, disconnectSignInMethod, myFactors, beginStepUp, completeStepUp, beginFactorEnrollment, completeFactorEnrollment, removeFactor, renameFactor, makeRecoveryCodes, myOrganizations, myOrganizationChoices, registerOrganization, saveSession, clearSession, getSessionToken, getSessionUser, signInWithPassword, signUpWithPassword, signInWithOTP, resetPasswordAndSignIn, refreshSession, refreshSessionOnce, signOut, authedRequest, authedOp, requireAuth, } from './auth';
|
|
14
14
|
export type { FoirAuthTokens, FoirUser, SessionAdapter, RequireAuthOptions, } from './auth';
|
|
15
15
|
export { userAccess, getSessionAccess, hasRole, hasScope, requireRole, requireScope, FoirAccessDeniedError, } from './access';
|
|
16
16
|
export type { FoirAccess, FoirAccessDenial } from './access';
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* How the pieces divide:
|
|
12
12
|
*
|
|
13
13
|
* cookie — long-lived (30d), HttpOnly, first-party on your registrable
|
|
14
|
-
* domain, set by Foir when hosted
|
|
14
|
+
* domain, set by Foir when hosted sign-in finalizes. JS cannot read
|
|
15
15
|
* it, so XSS cannot exfiltrate it.
|
|
16
16
|
* bearer — 15 minutes, held in a closure here. Never written to
|
|
17
17
|
* localStorage/sessionStorage/IndexedDB, never in a cookie of ours.
|