@avocadostudio-ai/orchestrator-core 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.
Files changed (84) hide show
  1. package/dist/agents/assistant/assemble.d.ts +29 -0
  2. package/dist/agents/assistant/assemble.js +151 -0
  3. package/dist/agents/assistant/builtin-skills.d.ts +1 -0
  4. package/dist/agents/assistant/builtin-skills.js +149 -0
  5. package/dist/agents/assistant/connectors/analytics.d.ts +27 -0
  6. package/dist/agents/assistant/connectors/analytics.js +109 -0
  7. package/dist/agents/assistant/connectors/drive.d.ts +15 -0
  8. package/dist/agents/assistant/connectors/drive.js +203 -0
  9. package/dist/agents/assistant/connectors/google-access.d.ts +11 -0
  10. package/dist/agents/assistant/connectors/google-access.js +21 -0
  11. package/dist/agents/assistant/connectors/pagespeed.d.ts +39 -0
  12. package/dist/agents/assistant/connectors/pagespeed.js +103 -0
  13. package/dist/agents/assistant/connectors/types.d.ts +31 -0
  14. package/dist/agents/assistant/connectors/types.js +1 -0
  15. package/dist/agents/assistant/connectors.d.ts +14 -0
  16. package/dist/agents/assistant/connectors.js +66 -0
  17. package/dist/agents/assistant/context.d.ts +14 -0
  18. package/dist/agents/assistant/context.js +67 -0
  19. package/dist/agents/assistant/media-sink.d.ts +12 -0
  20. package/dist/agents/assistant/media-sink.js +19 -0
  21. package/dist/agents/assistant/next-steps.d.ts +10 -0
  22. package/dist/agents/assistant/next-steps.js +34 -0
  23. package/dist/agents/assistant/permissions.d.ts +62 -0
  24. package/dist/agents/assistant/permissions.js +76 -0
  25. package/dist/agents/assistant/planner-tool.d.ts +9 -0
  26. package/dist/agents/assistant/planner-tool.js +79 -0
  27. package/dist/agents/assistant/router.d.ts +20 -0
  28. package/dist/agents/assistant/router.js +69 -0
  29. package/dist/agents/assistant/routines.d.ts +85 -0
  30. package/dist/agents/assistant/routines.js +142 -0
  31. package/dist/agents/assistant/site-connections.d.ts +17 -0
  32. package/dist/agents/assistant/site-connections.js +51 -0
  33. package/dist/agents/assistant/skills.d.ts +31 -0
  34. package/dist/agents/assistant/skills.js +79 -0
  35. package/dist/agents/learning.js +21 -7
  36. package/dist/agents/runtime/apply.d.ts +29 -0
  37. package/dist/agents/runtime/apply.js +72 -0
  38. package/dist/agents/runtime/approve.d.ts +71 -0
  39. package/dist/agents/runtime/approve.js +145 -0
  40. package/dist/agents/runtime/definition.d.ts +88 -0
  41. package/dist/agents/runtime/definition.js +12 -0
  42. package/dist/agents/runtime/engine.d.ts +88 -0
  43. package/dist/agents/runtime/engine.js +322 -0
  44. package/dist/agents/runtime/model.d.ts +34 -0
  45. package/dist/agents/runtime/model.js +80 -0
  46. package/dist/agents/runtime/registry.d.ts +22 -0
  47. package/dist/agents/runtime/registry.js +14 -0
  48. package/dist/agents/runtime/service.d.ts +62 -0
  49. package/dist/agents/runtime/service.js +217 -0
  50. package/dist/agents/runtime/toolkit.d.ts +64 -0
  51. package/dist/agents/runtime/toolkit.js +479 -0
  52. package/dist/agents/search/follow-up.d.ts +11 -0
  53. package/dist/agents/search/follow-up.js +76 -0
  54. package/dist/agents/search/opportunities.d.ts +42 -0
  55. package/dist/agents/search/opportunities.js +153 -0
  56. package/dist/agents/search/tools.d.ts +31 -0
  57. package/dist/agents/search/tools.js +134 -0
  58. package/dist/agents/tick.d.ts +3 -0
  59. package/dist/agents/tick.js +12 -0
  60. package/dist/checks/session-runner.js +14 -3
  61. package/dist/connections/google-auth.d.ts +55 -0
  62. package/dist/connections/google-auth.js +159 -0
  63. package/dist/connections/google-oauth.d.ts +60 -0
  64. package/dist/connections/google-oauth.js +207 -0
  65. package/dist/connections/search-console.d.ts +76 -0
  66. package/dist/connections/search-console.js +129 -0
  67. package/dist/connections/secrets.d.ts +15 -0
  68. package/dist/connections/secrets.js +88 -0
  69. package/dist/durable/in-memory-durable-store.d.ts +15 -1
  70. package/dist/durable/in-memory-durable-store.js +110 -1
  71. package/dist/durable/sqlite-durable-store.d.ts +19 -1
  72. package/dist/durable/sqlite-durable-store.js +198 -0
  73. package/dist/durable/types.d.ts +84 -0
  74. package/dist/handler/auth.js +7 -1
  75. package/dist/handler/create-orchestrator.d.ts +6 -1
  76. package/dist/handler/create-orchestrator.js +171 -3
  77. package/dist/http/agent-runtime-actions.d.ts +58 -0
  78. package/dist/http/agent-runtime-actions.js +247 -0
  79. package/dist/http/assistant-actions.d.ts +83 -0
  80. package/dist/http/assistant-actions.js +366 -0
  81. package/dist/http/connections-actions.d.ts +41 -0
  82. package/dist/http/connections-actions.js +145 -0
  83. package/dist/nlp/deterministic-planner-suggestions.js +23 -2
  84. package/package.json +3 -3
@@ -0,0 +1,203 @@
1
+ import { fenceSiteData } from "../../runtime/toolkit.js";
2
+ import { googleFor, googleUnavailable, serviceAccountEmail } from "./google-access.js";
3
+ import { readSiteConnections } from "../site-connections.js";
4
+ import { assistantMediaSink, imageExtension } from "../media-sink.js";
5
+ const DRIVE = "https://www.googleapis.com/drive/v3";
6
+ const SHEETS = "https://sheets.googleapis.com/v4/spreadsheets";
7
+ const SCOPES = ["https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/spreadsheets.readonly"];
8
+ const MAX_CHARS = 30_000;
9
+ const MAX_DEPTH = 4;
10
+ /** A folder link (https://drive.google.com/drive/folders/<id>) or a bare id → the id. */
11
+ export function driveFolderId(raw) {
12
+ if (!raw)
13
+ return null;
14
+ const text = raw.trim();
15
+ const fromLink = /\/folders\/([\w-]{10,})/.exec(text)?.[1];
16
+ if (fromLink)
17
+ return fromLink;
18
+ return /^[\w-]{10,}$/.test(text) ? text : null;
19
+ }
20
+ async function folderFor(ctx) {
21
+ return driveFolderId((await readSiteConnections(ctx.store, ctx.scopeKey))["google-drive"]?.folder);
22
+ }
23
+ const fileFields = "id,name,mimeType,modifiedTime,parents";
24
+ /** Whether a file sits in the folder, directly or a few folders down. */
25
+ export async function insideFolder(source, file, folderId) {
26
+ let parents = file.parents ?? [];
27
+ for (let depth = 0; depth < MAX_DEPTH && parents.length > 0; depth++) {
28
+ if (parents.includes(folderId))
29
+ return true;
30
+ const next = [];
31
+ for (const parent of parents) {
32
+ const meta = await source.json(`${DRIVE}/files/${encodeURIComponent(parent)}?fields=parents&supportsAllDrives=true`, SCOPES, {}, "Google Drive");
33
+ next.push(...(meta.parents ?? []));
34
+ }
35
+ parents = next;
36
+ }
37
+ return false;
38
+ }
39
+ const KIND = {
40
+ "application/vnd.google-apps.document": "Doc",
41
+ "application/vnd.google-apps.spreadsheet": "Sheet",
42
+ "application/vnd.google-apps.presentation": "Slides",
43
+ "application/vnd.google-apps.folder": "Folder",
44
+ "application/pdf": "PDF"
45
+ };
46
+ const listTool = {
47
+ name: "drive_list",
48
+ description: "List the owner's documents in the site's Google Drive folder: name, kind, when it last changed, and its id for drive_read. Pass `in` with a folder's id to look inside a subfolder.",
49
+ inputSchema: { type: "object", properties: { in: { type: "string", description: "A subfolder's id, from an earlier listing." } } },
50
+ run: async (input, ctx) => {
51
+ const root = await folderFor(ctx);
52
+ if (!root)
53
+ return { result: "No Drive folder is set for this site (Connections → Google Drive).", isError: true };
54
+ const source = await googleFor(ctx);
55
+ let folder = root;
56
+ if (typeof input.in === "string" && input.in && input.in !== root) {
57
+ const meta = await source.json(`${DRIVE}/files/${encodeURIComponent(input.in)}?fields=${fileFields}&supportsAllDrives=true`, SCOPES, {}, "Google Drive");
58
+ if (!(await insideFolder(source, meta, root)))
59
+ return { result: "That folder is not inside the site's Drive folder.", isError: true };
60
+ folder = meta.id;
61
+ }
62
+ const params = new URLSearchParams({
63
+ q: `'${folder}' in parents and trashed = false`,
64
+ fields: `files(${fileFields})`,
65
+ orderBy: "modifiedTime desc",
66
+ pageSize: "100",
67
+ supportsAllDrives: "true",
68
+ includeItemsFromAllDrives: "true"
69
+ });
70
+ const body = await source.json(`${DRIVE}/files?${params.toString()}`, SCOPES, {}, "Google Drive");
71
+ const files = body.files ?? [];
72
+ const lines = files.map((f) => `${f.id} | ${KIND[f.mimeType] ?? f.mimeType} | ${f.name} | changed ${f.modifiedTime?.slice(0, 10) ?? "?"}`);
73
+ return { result: fenceSiteData(lines.join("\n") || "(the folder is empty)"), trace: `listed ${files.length} documents` };
74
+ }
75
+ };
76
+ const readTool = {
77
+ name: "drive_read",
78
+ description: "Read one document from the site's Drive folder by its id: a Doc or Slides as text, a Sheet as rows (every tab), a text or CSV file as it is.",
79
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
80
+ run: async (input, ctx) => {
81
+ const root = await folderFor(ctx);
82
+ if (!root)
83
+ return { result: "No Drive folder is set for this site (Connections → Google Drive).", isError: true };
84
+ const id = typeof input.id === "string" ? input.id.trim() : "";
85
+ if (!/^[\w-]{10,}$/.test(id))
86
+ return { result: "Give a document id from drive_list.", isError: true };
87
+ const source = await googleFor(ctx);
88
+ const file = await source.json(`${DRIVE}/files/${encodeURIComponent(id)}?fields=${fileFields}&supportsAllDrives=true`, SCOPES, {}, "Google Drive");
89
+ if (!(await insideFolder(source, file, root)))
90
+ return { result: "That document is not inside the site's Drive folder.", isError: true };
91
+ let text;
92
+ if (file.mimeType === "application/vnd.google-apps.spreadsheet") {
93
+ const meta = await source.json(`${SHEETS}/${encodeURIComponent(id)}?fields=sheets.properties.title`, SCOPES, {}, "Google Sheets");
94
+ const tabs = (meta.sheets ?? []).map((s) => s.properties?.title ?? "").filter(Boolean).slice(0, 8);
95
+ const params = new URLSearchParams();
96
+ for (const tab of tabs)
97
+ params.append("ranges", `'${tab.replaceAll("'", "''")}'!A1:Z300`);
98
+ const values = await source.json(`${SHEETS}/${encodeURIComponent(id)}/values:batchGet?${params.toString()}`, SCOPES, {}, "Google Sheets");
99
+ text = (values.valueRanges ?? [])
100
+ .map((range, i) => [`## ${tabs[i] ?? range.range ?? "Sheet"}`, ...(range.values ?? []).map((row) => row.join(" | "))].join("\n"))
101
+ .join("\n\n");
102
+ if (text.length > MAX_CHARS)
103
+ text = `${text.slice(0, MAX_CHARS)}\n…(cut at ${MAX_CHARS} characters)`;
104
+ }
105
+ else if (file.mimeType === "application/vnd.google-apps.document" || file.mimeType === "application/vnd.google-apps.presentation") {
106
+ text = await source.text(`${DRIVE}/files/${encodeURIComponent(id)}/export?mimeType=text/plain`, SCOPES, MAX_CHARS, "Google Drive");
107
+ }
108
+ else if (file.mimeType.startsWith("text/") || file.mimeType === "application/json") {
109
+ text = await source.text(`${DRIVE}/files/${encodeURIComponent(id)}?alt=media&supportsAllDrives=true`, SCOPES, MAX_CHARS, "Google Drive");
110
+ }
111
+ else {
112
+ return { result: `${file.name} is a ${KIND[file.mimeType] ?? file.mimeType}, which cannot be read as text yet. Ask the owner for a Doc or Sheet version.`, isError: true };
113
+ }
114
+ return { result: fenceSiteData(`${file.name} (changed ${file.modifiedTime?.slice(0, 10) ?? "?"})\n\n${text}`), trace: `read ${file.name}` };
115
+ }
116
+ };
117
+ const MAX_IMAGES = 24;
118
+ const MAX_IMAGE_BYTES = 10_000_000;
119
+ /**
120
+ * The owner's images onto the site: each one read from the site's folder and
121
+ * stored where the host keeps the site's media (media-sink.ts). Only the chat
122
+ * assistant has it, because it adds files to the site; a routine only proposes.
123
+ */
124
+ const importImagesTool = {
125
+ name: "drive_import_images",
126
+ effect: "apply",
127
+ description: "Bring images from the site's Google Drive folder onto the site, so pages can use them. Give the image ids from drive_list, or none for every image in the folder (up to 24). Returns each image's name and its address on the site; use those addresses in the page (edit_page), and write alt text from what the person said or the file name.",
128
+ inputSchema: { type: "object", properties: { ids: { type: "array", items: { type: "string" } } } },
129
+ run: async (input, ctx) => {
130
+ if (ctx.agentId !== "assistant")
131
+ return { result: "Only the chat assistant brings images onto the site.", isError: true };
132
+ const root = await folderFor(ctx);
133
+ if (!root)
134
+ return { result: "No Drive folder is set for this site (Connections → Google Drive).", isError: true };
135
+ const sink = assistantMediaSink();
136
+ if (!sink)
137
+ return { result: "This site has nowhere to store images yet.", isError: true };
138
+ const source = await googleFor(ctx);
139
+ const wanted = Array.isArray(input.ids) ? input.ids.filter((id) => typeof id === "string" && /^[\w-]{10,}$/.test(id)) : [];
140
+ let files;
141
+ if (wanted.length > 0) {
142
+ files = [];
143
+ for (const id of wanted.slice(0, MAX_IMAGES)) {
144
+ files.push(await source.json(`${DRIVE}/files/${encodeURIComponent(id)}?fields=${fileFields}&supportsAllDrives=true`, SCOPES, {}, "Google Drive"));
145
+ }
146
+ }
147
+ else {
148
+ const params = new URLSearchParams({
149
+ q: `'${root}' in parents and trashed = false and mimeType contains 'image/'`,
150
+ fields: `files(${fileFields})`,
151
+ orderBy: "name",
152
+ pageSize: String(MAX_IMAGES),
153
+ supportsAllDrives: "true",
154
+ includeItemsFromAllDrives: "true"
155
+ });
156
+ files = (await source.json(`${DRIVE}/files?${params.toString()}`, SCOPES, {}, "Google Drive")).files ?? [];
157
+ }
158
+ const lines = [];
159
+ let imported = 0;
160
+ for (const file of files) {
161
+ if (!(await insideFolder(source, file, root))) {
162
+ lines.push(`${file.name}: not inside the site's Drive folder, skipped`);
163
+ continue;
164
+ }
165
+ if (!imageExtension(file.mimeType)) {
166
+ lines.push(`${file.name}: not an image the site can show (${file.mimeType}), skipped`);
167
+ continue;
168
+ }
169
+ try {
170
+ const { data } = await source.bytes(`${DRIVE}/files/${encodeURIComponent(file.id)}?alt=media&supportsAllDrives=true`, SCOPES, MAX_IMAGE_BYTES, "Google Drive");
171
+ const filename = /\.[a-z0-9]+$/i.test(file.name) ? file.name : `${file.name}.${imageExtension(file.mimeType)}`;
172
+ const stored = await sink({ filename, contentType: file.mimeType, data });
173
+ lines.push(`${file.name} | ${stored.url}`);
174
+ imported += 1;
175
+ }
176
+ catch (error) {
177
+ lines.push(`${file.name}: ${error instanceof Error ? error.message : String(error)}`);
178
+ }
179
+ }
180
+ if (files.length === 0)
181
+ return { result: "There are no images in the site's Drive folder.", isError: true };
182
+ return {
183
+ result: fenceSiteData(`Imported ${imported} of ${files.length} images. name | address on the site:\n${lines.join("\n")}`),
184
+ trace: `imported ${imported} images`,
185
+ ...(imported === 0 ? { isError: true } : {})
186
+ };
187
+ }
188
+ };
189
+ export const driveConnector = {
190
+ id: "google-drive",
191
+ name: "Google Drive",
192
+ description: "The owner's documents and sheets in one shared folder: prices, event plans, brand rules. Read-only.",
193
+ fields: [{ key: "folder", label: "Drive folder (link or ID)", placeholder: "https://drive.google.com/drive/folders/…" }],
194
+ tools: [listTool, readTool, importImagesTool],
195
+ grantTo: serviceAccountEmail,
196
+ status: (settings, all) => {
197
+ const unavailable = googleUnavailable(all);
198
+ if (unavailable)
199
+ return { connected: false, reason: unavailable };
200
+ const folder = driveFolderId(settings.folder);
201
+ return folder ? { connected: true, detail: `folder ${folder}` } : { connected: false, reason: "Set the Drive folder to read." };
202
+ }
203
+ };
@@ -0,0 +1,11 @@
1
+ import type { DurableStore } from "../../../durable/types.ts";
2
+ import { type GoogleAccess } from "../../../connections/google-auth.ts";
3
+ import { type SiteConnections } from "../site-connections.ts";
4
+ /** Null when the site signed in, or there is no service account: there is then nobody to grant access to. */
5
+ export declare function serviceAccountEmail(): string | null;
6
+ /** Why a site cannot reach Google, or null when it can. */
7
+ export declare function googleUnavailable(all: SiteConnections): string | null;
8
+ export declare function googleFor(ctx: {
9
+ store: DurableStore;
10
+ scopeKey: string;
11
+ }): Promise<GoogleAccess>;
@@ -0,0 +1,21 @@
1
+ import { googleTokenSource } from "../../../connections/google-auth.js";
2
+ import { googleAccessFor } from "../../../connections/google-oauth.js";
3
+ import { googleSignedIn } from "../site-connections.js";
4
+ /** Null when the site signed in, or there is no service account: there is then nobody to grant access to. */
5
+ export function serviceAccountEmail() {
6
+ const source = googleTokenSource();
7
+ return "error" in source ? null : source.account.client_email;
8
+ }
9
+ /** Why a site cannot reach Google, or null when it can. */
10
+ export function googleUnavailable(all) {
11
+ if (googleSignedIn(all))
12
+ return null;
13
+ const source = googleTokenSource();
14
+ return "error" in source ? "Connect Google for this site." : null;
15
+ }
16
+ export async function googleFor(ctx) {
17
+ const access = await googleAccessFor(ctx.store, ctx.scopeKey);
18
+ if ("error" in access)
19
+ throw new Error(access.error);
20
+ return access;
21
+ }
@@ -0,0 +1,39 @@
1
+ import type { Connector } from "./types.ts";
2
+ type Audit = {
3
+ title?: string;
4
+ displayValue?: string;
5
+ numericValue?: number;
6
+ details?: {
7
+ type?: string;
8
+ overallSavingsMs?: number;
9
+ };
10
+ };
11
+ type FieldMetric = {
12
+ percentile?: number;
13
+ category?: string;
14
+ };
15
+ export type PageSpeedResponse = {
16
+ lighthouseResult?: {
17
+ categories?: {
18
+ performance?: {
19
+ score?: number;
20
+ };
21
+ };
22
+ audits?: Record<string, Audit>;
23
+ };
24
+ loadingExperience?: {
25
+ overall_category?: string;
26
+ metrics?: Record<string, FieldMetric>;
27
+ };
28
+ error?: {
29
+ message?: string;
30
+ };
31
+ };
32
+ /** Tests answer PageSpeed with a stub. */
33
+ export declare function setPageSpeedFetchForTests(fn: typeof fetch | null): void;
34
+ /** The site's public origin: its own setting, else the Search Console property when that is a URL. */
35
+ export declare function publicOrigin(settings: Record<string, string>, searchConsole?: Record<string, string>): string | null;
36
+ /** The response, in lines a model and an owner can both read. */
37
+ export declare function describePageSpeed(url: string, strategy: string, body: PageSpeedResponse): string;
38
+ export declare const pageSpeedConnector: Connector;
39
+ export {};
@@ -0,0 +1,103 @@
1
+ import { fenceSiteData } from "../../runtime/toolkit.js";
2
+ import { getSecret } from "../../../connections/secrets.js";
3
+ import { connectorSecretName, readSiteConnections } from "../site-connections.js";
4
+ const API = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
5
+ let fetchOverride = null;
6
+ /** Tests answer PageSpeed with a stub. */
7
+ export function setPageSpeedFetchForTests(fn) {
8
+ fetchOverride = fn;
9
+ }
10
+ /** The site's public origin: its own setting, else the Search Console property when that is a URL. */
11
+ export function publicOrigin(settings, searchConsole = {}) {
12
+ for (const candidate of [settings.siteUrl, searchConsole.property, process.env.GSC_PROPERTY]) {
13
+ if (!candidate || candidate.startsWith("sc-domain:"))
14
+ continue;
15
+ try {
16
+ return new URL(candidate.trim()).origin;
17
+ }
18
+ catch {
19
+ // Not a URL; try the next one.
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+ const ms = (value) => (typeof value === "number" ? `${(value / 1000).toFixed(1)} s` : "n/a");
25
+ /** The response, in lines a model and an owner can both read. */
26
+ export function describePageSpeed(url, strategy, body) {
27
+ const lab = body.lighthouseResult;
28
+ const score = lab?.categories?.performance?.score;
29
+ const audits = lab?.audits ?? {};
30
+ const field = body.loadingExperience?.metrics ?? {};
31
+ const lines = [
32
+ `${url} (${strategy})`,
33
+ `performance score: ${typeof score === "number" ? Math.round(score * 100) : "n/a"}/100`,
34
+ `lab: largest content ${audits["largest-contentful-paint"]?.displayValue ?? "n/a"}, layout shift ${audits["cumulative-layout-shift"]?.displayValue ?? "n/a"}, blocking time ${audits["total-blocking-time"]?.displayValue ?? "n/a"}`
35
+ ];
36
+ if (field.LARGEST_CONTENTFUL_PAINT_MS || field.INTERACTION_TO_NEXT_PAINT) {
37
+ lines.push(`real visitors (28 days, ${body.loadingExperience?.overall_category ?? "?"}): largest content ${ms(field.LARGEST_CONTENTFUL_PAINT_MS?.percentile)}, ` +
38
+ `response to input ${field.INTERACTION_TO_NEXT_PAINT?.percentile ?? "n/a"} ms, layout shift ${((field.CUMULATIVE_LAYOUT_SHIFT_SCORE?.percentile ?? 0) / 100).toFixed(2)}`);
39
+ }
40
+ else {
41
+ lines.push("real visitors: not enough Chrome data for this page");
42
+ }
43
+ const opportunities = Object.values(audits)
44
+ .filter((audit) => audit.details?.type === "opportunity" && (audit.details.overallSavingsMs ?? 0) >= 100)
45
+ .sort((a, b) => (b.details?.overallSavingsMs ?? 0) - (a.details?.overallSavingsMs ?? 0))
46
+ .slice(0, 5)
47
+ .map((audit) => `- ${audit.title} (saves about ${ms(audit.details?.overallSavingsMs)})`);
48
+ lines.push(opportunities.length ? `biggest savings:\n${opportunities.join("\n")}` : "no large savings found");
49
+ return lines.join("\n");
50
+ }
51
+ const pageSpeedTool = {
52
+ name: "page_speed",
53
+ description: "Measure how fast one page loads, with Google PageSpeed Insights: a performance score, real-visitor numbers when Google has them, and the biggest things slowing it. Takes up to half a minute per page; measure a few important pages, not all.",
54
+ inputSchema: {
55
+ type: "object",
56
+ properties: { page: { type: "string", description: "A page slug, e.g. /events/." }, strategy: { type: "string", enum: ["mobile", "desktop"] } },
57
+ required: ["page"]
58
+ },
59
+ run: async (input, ctx) => {
60
+ const connections = await readSiteConnections(ctx.store, ctx.scopeKey);
61
+ const origin = publicOrigin(connections.pagespeed ?? {}, connections["search-console"] ?? {});
62
+ if (!origin)
63
+ return { result: "The site's public address is not set (Connections → PageSpeed).", isError: true };
64
+ const slug = typeof input.page === "string" && input.page.startsWith("/") ? input.page : "/";
65
+ const strategy = input.strategy === "desktop" ? "desktop" : "mobile";
66
+ const url = `${origin}${slug}`;
67
+ const params = new URLSearchParams({ url, strategy, category: "performance" });
68
+ const apiKey = (await getSecret(ctx.store, ctx.scopeKey, connectorSecretName("pagespeed", "apiKey"))) ?? process.env.PAGESPEED_API_KEY?.trim();
69
+ if (apiKey)
70
+ params.set("key", apiKey);
71
+ const res = await (fetchOverride ?? fetch)(`${API}?${params.toString()}`);
72
+ const body = (await res.json().catch(() => ({})));
73
+ if (res.status === 429) {
74
+ return {
75
+ result: apiKey
76
+ ? "PageSpeed's daily quota for this key is used up; try again tomorrow."
77
+ : "PageSpeed refused: without an API key, Google's shared daily quota is used up. Add a (free) key in Connections → PageSpeed.",
78
+ isError: true
79
+ };
80
+ }
81
+ if (!res.ok)
82
+ return { result: `PageSpeed answered ${res.status}: ${body.error?.message ?? "no reason given"}`, isError: true };
83
+ return { result: fenceSiteData(describePageSpeed(url, strategy, body)), trace: `measured ${slug} (${strategy})` };
84
+ }
85
+ };
86
+ export const pageSpeedConnector = {
87
+ id: "pagespeed",
88
+ name: "PageSpeed Insights",
89
+ description: "How fast pages load for real visitors, and what slows them down. Public data, nothing to grant.",
90
+ fields: [
91
+ { key: "siteUrl", label: "Public address of the site", placeholder: "https://example.com" },
92
+ { key: "apiKey", label: "API key", secret: true, helpUrl: "https://developers.google.com/speed/docs/insights/v5/get-started#APIKey" }
93
+ ],
94
+ tools: [pageSpeedTool],
95
+ status: (settings, all) => {
96
+ const origin = publicOrigin(settings, all["search-console"] ?? {});
97
+ if (!origin)
98
+ return { connected: false, reason: "Set the site's public address." };
99
+ if (!settings.apiKey && !process.env.PAGESPEED_API_KEY)
100
+ return { connected: false, reason: "Add an API key (free): without one, Google's shared daily quota is used up." };
101
+ return { connected: true, detail: origin };
102
+ }
103
+ };
@@ -0,0 +1,31 @@
1
+ import type { RuntimeTool } from "../../runtime/definition.ts";
2
+ import type { SiteConnections } from "../site-connections.ts";
3
+ export type ConnectorStatus = {
4
+ connected: true;
5
+ detail?: string;
6
+ } | {
7
+ connected: false;
8
+ reason: string;
9
+ };
10
+ /** A setting a site fills in, e.g. its Analytics property. Labels are the editor's to translate; these are defaults. */
11
+ export type ConnectorField = {
12
+ key: string;
13
+ label: string;
14
+ placeholder?: string;
15
+ /** An API key and the like: kept encrypted (secrets.ts), shown back only as a hint. */
16
+ secret?: boolean;
17
+ /** Where the owner gets one, when it is not obvious. */
18
+ helpUrl?: string;
19
+ };
20
+ export type Connector = {
21
+ id: string;
22
+ name: string;
23
+ /** One line for people: what it gives the assistant. */
24
+ description: string;
25
+ fields: ConnectorField[];
26
+ tools: RuntimeTool[];
27
+ /** Connected for a site with these settings (and its other connections')? Synchronous: no network. */
28
+ status: (settings: Record<string, string>, all: SiteConnections) => ConnectorStatus;
29
+ /** Who to give access to, when access is granted by sharing (a service account's email). */
30
+ grantTo?: () => string | null;
31
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { Connector, ConnectorStatus } from "./connectors/types.ts";
2
+ import type { SiteConnections } from "./site-connections.ts";
3
+ export type { Connector, ConnectorField, ConnectorStatus } from "./connectors/types.ts";
4
+ export declare const CONNECTORS: Connector[];
5
+ /** Tests add a connector for their own duration. */
6
+ export declare function registerConnectorForTests(connector: Connector): () => void;
7
+ export declare function allConnectors(): Connector[];
8
+ export declare function getConnector(id: string): Connector | undefined;
9
+ /** A connector's state for a site. */
10
+ export declare function connectorStatus(connector: Connector, connections?: SiteConnections): ConnectorStatus;
11
+ /** Why a set of connectors cannot all be used for a site, or null when they can. */
12
+ export declare function missingConnector(ids: string[], connections?: SiteConnections): string | null;
13
+ /** The connectors connected for a site, in order. */
14
+ export declare function connectedConnectors(connections?: SiteConnections): Connector[];
@@ -0,0 +1,66 @@
1
+ /*
2
+ * Connectors: data from outside the site (assistant concept §2.4).
3
+ *
4
+ * Google Search Console what people search for, and what they click
5
+ * Google Analytics visits and key events (bookings, enquiries)
6
+ * Google Drive the owner's own documents and sheets
7
+ * PageSpeed Insights how fast pages load, and what slows them
8
+ *
9
+ * Each is connected per site: the site's settings (which property, which
10
+ * folder, which address) live with its assistant settings, and the Google ones
11
+ * share the deployment's service account (connections/google-auth.ts), which
12
+ * the site grants access to like a colleague. Everything a connector returns is
13
+ * untrusted content and is fenced like site text.
14
+ */
15
+ import { SEARCH_TOOLS, searchConsoleStatus } from "../search/tools.js";
16
+ import { serviceAccountEmail } from "./connectors/google-access.js";
17
+ import { analyticsConnector } from "./connectors/analytics.js";
18
+ import { driveConnector } from "./connectors/drive.js";
19
+ import { pageSpeedConnector } from "./connectors/pagespeed.js";
20
+ const searchConsoleConnector = {
21
+ id: "search-console",
22
+ name: "Google Search Console",
23
+ description: "What people search for on Google, and how often they see and click each page. Read-only.",
24
+ fields: [{ key: "property", label: "Search Console property", placeholder: "https://example.com/ or sc-domain:example.com" }],
25
+ tools: SEARCH_TOOLS,
26
+ grantTo: serviceAccountEmail,
27
+ status: (settings, all) => {
28
+ const status = searchConsoleStatus(settings, all);
29
+ return "error" in status ? { connected: false, reason: status.error } : { connected: true, detail: status.property };
30
+ }
31
+ };
32
+ export const CONNECTORS = [searchConsoleConnector, analyticsConnector, driveConnector, pageSpeedConnector];
33
+ let extra = [];
34
+ /** Tests add a connector for their own duration. */
35
+ export function registerConnectorForTests(connector) {
36
+ extra = [...extra, connector];
37
+ return () => {
38
+ extra = extra.filter((c) => c !== connector);
39
+ };
40
+ }
41
+ export function allConnectors() {
42
+ return [...CONNECTORS, ...extra];
43
+ }
44
+ export function getConnector(id) {
45
+ return allConnectors().find((connector) => connector.id === id);
46
+ }
47
+ /** A connector's state for a site. */
48
+ export function connectorStatus(connector, connections = {}) {
49
+ return connector.status(connections[connector.id] ?? {}, connections);
50
+ }
51
+ /** Why a set of connectors cannot all be used for a site, or null when they can. */
52
+ export function missingConnector(ids, connections = {}) {
53
+ for (const id of ids) {
54
+ const connector = getConnector(id);
55
+ if (!connector)
56
+ return `Unknown connector ${id}.`;
57
+ const status = connectorStatus(connector, connections);
58
+ if (!status.connected)
59
+ return status.reason;
60
+ }
61
+ return null;
62
+ }
63
+ /** The connectors connected for a site, in order. */
64
+ export function connectedConnectors(connections = {}) {
65
+ return allConnectors().filter((connector) => connectorStatus(connector, connections).connected);
66
+ }
@@ -0,0 +1,14 @@
1
+ import type { DurableStore } from "../../durable/types.ts";
2
+ export declare const CONTEXT_SECTIONS: readonly ["business", "voice", "goals", "avoid"];
3
+ export type ContextSection = (typeof CONTEXT_SECTIONS)[number];
4
+ export declare const CONTEXT_SECTION_MAX_CHARS = 2000;
5
+ export declare function isContextSection(value: unknown): value is ContextSection;
6
+ export declare function readContextSections(store: DurableStore, scopeKey: string): Promise<Record<ContextSection, string>>;
7
+ /** Set one section. An empty value clears it. */
8
+ export declare function writeContextSection(store: DurableStore, scopeKey: string, section: ContextSection, value: string, by?: string): Promise<void>;
9
+ /**
10
+ * The context block a run starts with, or "" when the site has none. Facts an
11
+ * agent recorded came from reading pages or connectors, so they are kept apart
12
+ * from what the owner stated, and labelled.
13
+ */
14
+ export declare function siteContextBlock(store: DurableStore, scopeKey: string): Promise<string>;
@@ -0,0 +1,67 @@
1
+ import { SITE_FACT_KINDS } from "../learning.js";
2
+ export const CONTEXT_SECTIONS = ["business", "voice", "goals", "avoid"];
3
+ export const CONTEXT_SECTION_MAX_CHARS = 2_000;
4
+ const LABEL = {
5
+ business: "About the business",
6
+ voice: "Voice and tone",
7
+ goals: "What counts as a win",
8
+ avoid: "Do not touch"
9
+ };
10
+ export function isContextSection(value) {
11
+ return typeof value === "string" && CONTEXT_SECTIONS.includes(value);
12
+ }
13
+ const keyOf = (section) => `about.${section}`;
14
+ export async function readContextSections(store, scopeKey) {
15
+ const rows = await store.listMemory({ scopeKey, scope: "site", kind: "decision", status: "active", limit: 50 });
16
+ const sections = Object.fromEntries(CONTEXT_SECTIONS.map((s) => [s, ""]));
17
+ for (const section of CONTEXT_SECTIONS) {
18
+ sections[section] = rows.find((row) => row.key === keyOf(section))?.value ?? "";
19
+ }
20
+ return sections;
21
+ }
22
+ /** Set one section. An empty value clears it. */
23
+ export async function writeContextSection(store, scopeKey, section, value, by) {
24
+ const text = value.trim().slice(0, CONTEXT_SECTION_MAX_CHARS);
25
+ if (!text) {
26
+ const rows = await store.listMemory({ scopeKey, scope: "site", kind: "decision", status: "active", limit: 50 });
27
+ for (const row of rows.filter((r) => r.key === keyOf(section)))
28
+ await store.setMemoryStatus(row.id, "rejected");
29
+ return;
30
+ }
31
+ await store.putMemory({
32
+ scope: "site",
33
+ scopeKey,
34
+ kind: "decision",
35
+ key: keyOf(section),
36
+ value: text,
37
+ source: "user",
38
+ ...(by ? { sourceRef: `person:${by}` } : {}),
39
+ confidence: 1
40
+ });
41
+ }
42
+ const factLine = (fact) => (fact.key.startsWith("user:") ? `- ${fact.value}` : `- ${fact.key}: ${fact.value}`);
43
+ /**
44
+ * The context block a run starts with, or "" when the site has none. Facts an
45
+ * agent recorded came from reading pages or connectors, so they are kept apart
46
+ * from what the owner stated, and labelled.
47
+ */
48
+ export async function siteContextBlock(store, scopeKey) {
49
+ const [sections, facts] = await Promise.all([
50
+ readContextSections(store, scopeKey),
51
+ store.listMemory({ scopeKey, scope: "site", kind: [...SITE_FACT_KINDS], status: "active", limit: 100 })
52
+ ]);
53
+ const parts = [];
54
+ for (const section of CONTEXT_SECTIONS) {
55
+ if (sections[section])
56
+ parts.push(`${LABEL[section]}:\n${sections[section]}`);
57
+ }
58
+ const stated = facts.filter((fact) => fact.source === "user" || fact.source === "import");
59
+ const learned = facts.filter((fact) => fact.source !== "user" && fact.source !== "import");
60
+ if (stated.length)
61
+ parts.push(`Facts the owner gave:\n${stated.map(factLine).join("\n")}`);
62
+ if (learned.length)
63
+ parts.push(`Facts an agent recorded (not confirmed by the owner; check before relying on one):\n${learned.map(factLine).join("\n")}`);
64
+ if (parts.length === 0)
65
+ return "";
66
+ return `<site_context>\n${parts.join("\n\n").replaceAll("</site_context>", "</site_context_>")}\n</site_context>`;
67
+ }
@@ -0,0 +1,12 @@
1
+ export type MediaFile = {
2
+ filename: string;
3
+ contentType: string;
4
+ data: Uint8Array;
5
+ };
6
+ export type MediaSink = (file: MediaFile) => Promise<{
7
+ url: string;
8
+ }>;
9
+ export declare function setAssistantMediaSink(next: MediaSink | null): void;
10
+ export declare function assistantMediaSink(): MediaSink | null;
11
+ /** The file extension for an image type the site can serve, or null for anything else. */
12
+ export declare function imageExtension(contentType: string): string | null;
@@ -0,0 +1,19 @@
1
+ let sink = null;
2
+ export function setAssistantMediaSink(next) {
3
+ sink = next;
4
+ }
5
+ export function assistantMediaSink() {
6
+ return sink;
7
+ }
8
+ /** The file extension for an image type the site can serve, or null for anything else. */
9
+ export function imageExtension(contentType) {
10
+ const map = {
11
+ "image/png": "png",
12
+ "image/jpeg": "jpg",
13
+ "image/webp": "webp",
14
+ "image/gif": "gif",
15
+ "image/avif": "avif"
16
+ };
17
+ // Not SVG: served from the orchestrator's own origin, an SVG can carry script.
18
+ return map[contentType.toLowerCase()] ?? null;
19
+ }
@@ -0,0 +1,10 @@
1
+ import type { RuntimeTool } from "../runtime/definition.ts";
2
+ export declare const nextStepsTool: RuntimeTool;
3
+ /** The chips from a run's trace lines: the last next_steps call's, or none. */
4
+ export declare function nextStepsFrom(calls: Array<{
5
+ name: string;
6
+ result: string;
7
+ }>): {
8
+ options: string[];
9
+ routine?: string;
10
+ } | null;