@agent-native/core 0.101.4 → 0.101.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +13 -0
- package/corpus/core/docs/content/locales/ar-SA/pure-agent-apps.mdx +2 -2
- package/corpus/core/docs/content/locales/de-DE/pure-agent-apps.mdx +2 -2
- package/corpus/core/docs/content/locales/es-ES/pure-agent-apps.mdx +2 -2
- package/corpus/core/docs/content/locales/fr-FR/pure-agent-apps.mdx +2 -2
- package/corpus/core/docs/content/locales/hi-IN/pure-agent-apps.mdx +2 -2
- package/corpus/core/docs/content/locales/ja-JP/pure-agent-apps.mdx +2 -2
- package/corpus/core/docs/content/locales/ko-KR/pure-agent-apps.mdx +2 -2
- package/corpus/core/docs/content/locales/pt-BR/pure-agent-apps.mdx +2 -2
- package/corpus/core/docs/content/locales/zh-CN/pure-agent-apps.mdx +2 -2
- package/corpus/core/docs/content/locales/zh-TW/pure-agent-apps.mdx +2 -2
- package/corpus/core/docs/content/pure-agent-apps.mdx +58 -14
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/action.ts +15 -0
- package/corpus/core/src/agent/production-agent.ts +215 -19
- package/corpus/core/src/coding-tools/run-code.ts +11 -5
- package/corpus/core/src/extensions/actions.ts +15 -0
- package/corpus/core/src/extensions/url-safety.ts +11 -1
- package/corpus/core/src/notifications/channels.ts +61 -30
- package/corpus/core/src/server/action-discovery.ts +4 -1
- package/corpus/core/src/server/agent-chat/context-tools.ts +4 -0
- package/corpus/templates/analytics/AGENTS.md +6 -0
- package/corpus/templates/analytics/README.md +1 -1
- package/corpus/templates/analytics/actions/data-source-status.ts +19 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +5 -1
- package/corpus/templates/analytics/app/routes/_index.tsx +2 -2
- package/corpus/templates/analytics/changelog/2026-07-14-analytics-chat-guides-you-to-connect-a-data-source-with-a-di.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-14-analytics-landing-pages-now-clearly-position-the-app-as-an-o.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-15-daily-dashboard-emails-now-include-the-complete-dashboard-sc.md +6 -0
- package/corpus/templates/analytics/server/jobs/dashboard-report.ts +8 -1
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +64 -12
- package/corpus/templates/analytics/server/lib/real-data-actions.ts +15 -3
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +364 -4
- package/corpus/templates/clips/app/components/dictate/vocabulary-section.tsx +1 -0
- package/corpus/templates/clips/app/components/library/folder-tree.tsx +12 -9
- package/corpus/templates/clips/app/components/library/library-layout.tsx +19 -16
- package/corpus/templates/clips/app/routes/_app.spaces._index.tsx +13 -9
- package/corpus/templates/clips/changelog/2026-07-15-folders-and-sidebar-links-are-clickable-again-immediately-af.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-hovering-a-truncated-folder-name-now-shows-the-full-name.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-space-creation-controls-are-now-shown-only-to-organization-a.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-vocabulary-terms-can-now-be-deleted-from-the-dictation-dicti.md +6 -0
- package/corpus/templates/clips/desktop/src/styles.css +23 -1
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +49 -7
- package/corpus/templates/clips/server/lib/recordings.ts +5 -2
- package/dist/action.d.ts +12 -0
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +2 -0
- package/dist/action.js.map +1 -1
- package/dist/agent/production-agent.d.ts +24 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +175 -18
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/coding-tools/run-code.d.ts +5 -2
- package/dist/coding-tools/run-code.d.ts.map +1 -1
- package/dist/coding-tools/run-code.js +11 -5
- package/dist/coding-tools/run-code.js.map +1 -1
- package/dist/extensions/actions.d.ts.map +1 -1
- package/dist/extensions/actions.js +14 -0
- package/dist/extensions/actions.js.map +1 -1
- package/dist/extensions/url-safety.d.ts +6 -0
- package/dist/extensions/url-safety.d.ts.map +1 -1
- package/dist/extensions/url-safety.js +6 -0
- package/dist/extensions/url-safety.js.map +1 -1
- package/dist/notifications/channels.d.ts.map +1 -1
- package/dist/notifications/channels.js +38 -22
- package/dist/notifications/channels.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/server/action-discovery.d.ts.map +1 -1
- package/dist/server/action-discovery.js +4 -1
- package/dist/server/action-discovery.js.map +1 -1
- package/dist/server/agent-chat/context-tools.d.ts.map +1 -1
- package/dist/server/agent-chat/context-tools.js +4 -0
- package/dist/server/agent-chat/context-tools.js.map +1 -1
- package/docs/content/locales/ar-SA/pure-agent-apps.mdx +2 -2
- package/docs/content/locales/de-DE/pure-agent-apps.mdx +2 -2
- package/docs/content/locales/es-ES/pure-agent-apps.mdx +2 -2
- package/docs/content/locales/fr-FR/pure-agent-apps.mdx +2 -2
- package/docs/content/locales/hi-IN/pure-agent-apps.mdx +2 -2
- package/docs/content/locales/ja-JP/pure-agent-apps.mdx +2 -2
- package/docs/content/locales/ko-KR/pure-agent-apps.mdx +2 -2
- package/docs/content/locales/pt-BR/pure-agent-apps.mdx +2 -2
- package/docs/content/locales/zh-CN/pure-agent-apps.mdx +2 -2
- package/docs/content/locales/zh-TW/pure-agent-apps.mdx +2 -2
- package/docs/content/pure-agent-apps.mdx +58 -14
- package/package.json +1 -1
|
@@ -139,7 +139,7 @@ export function createRunCodeEntry(
|
|
|
139
139
|
" - `workspaceList(prefix?)` — list workspace files, returns [{ path, sizeBytes, contentType, updatedAt }].",
|
|
140
140
|
"Print results with `console.log()`; only stdout+stderr are returned.",
|
|
141
141
|
"Timeout defaults to 120 s (max 600 s). Output is truncated to 50 000 chars by default (max 200 000).",
|
|
142
|
-
'For LONG compute — big cross-source joins, corpus-wide sweeps, scripts that could exceed ~30 s, or anything at risk of dying with the current chat run — pass `background: true`. That enqueues a durable execution and returns `{ executionId, status: "queued" }` immediately; the code runs out-of-band with a generous budget (default 10 min) and its result survives run timeouts. Continue other work, then poll
|
|
142
|
+
'For LONG compute — big cross-source joins, corpus-wide sweeps, scripts that could exceed ~30 s, or anything at risk of dying with the current chat run — pass `background: true`. That enqueues a durable execution and returns `{ executionId, status: "queued" }` immediately; the code runs out-of-band with a generous budget (default 10 min) and its result survives run timeouts. Continue other work, then poll with `get-code-execution` when that dedicated tool is available; legacy hosts can call run-code again with just `{ executionId }`. Keep quick scripts in the default foreground mode.',
|
|
143
143
|
].join(" "),
|
|
144
144
|
parameters: {
|
|
145
145
|
type: "object",
|
|
@@ -160,12 +160,12 @@ export function createRunCodeEntry(
|
|
|
160
160
|
background: {
|
|
161
161
|
type: "boolean",
|
|
162
162
|
description:
|
|
163
|
-
'Run as a durable background execution: returns { executionId, status: "queued" } immediately and the code executes out-of-band, surviving chat-run timeouts. Use for long compute (large joins, multi-page provider sweeps, heavy analysis). Poll with
|
|
163
|
+
'Run as a durable background execution: returns { executionId, status: "queued" } immediately and the code executes out-of-band, surviving chat-run timeouts. Use for long compute (large joins, multi-page provider sweeps, heavy analysis). Poll with get-code-execution when available.',
|
|
164
164
|
},
|
|
165
165
|
executionId: {
|
|
166
166
|
type: "string",
|
|
167
167
|
description:
|
|
168
|
-
"
|
|
168
|
+
"Legacy polling fallback for hosts without get-code-execution: pass the executionId alone (no code) to get status and output.",
|
|
169
169
|
},
|
|
170
170
|
},
|
|
171
171
|
required: [],
|
|
@@ -582,12 +582,18 @@ function formatTerminalSandboxExecution(row: SandboxExecutionRow): string {
|
|
|
582
582
|
/**
|
|
583
583
|
* Standalone, access-scoped poll tool for background executions. Behaviorally
|
|
584
584
|
* identical to calling `run-code` with only `executionId`; hosts that register
|
|
585
|
-
* it as `get-code-execution` give the model a dedicated read tool (and
|
|
586
|
-
* enqueue guidance automatically points at it when present in the
|
|
585
|
+
* it as `get-code-execution` give the model a dedicated volatile read tool (and
|
|
586
|
+
* the enqueue guidance automatically points at it when present in the
|
|
587
|
+
* registry). Keep the opt-out here rather than on `run-code`: repeated normal
|
|
588
|
+
* run-code calls may execute writes or outbound requests and must retain the
|
|
589
|
+
* agent loop's default duplicate-call protection.
|
|
587
590
|
*/
|
|
588
591
|
export function createGetCodeExecutionEntry(): ActionEntry {
|
|
589
592
|
return {
|
|
590
593
|
readOnly: true,
|
|
594
|
+
// Polling with an identical executionId is the intended usage — the
|
|
595
|
+
// status changes over time, so this must not be deduped.
|
|
596
|
+
dedupe: false,
|
|
591
597
|
tool: {
|
|
592
598
|
description:
|
|
593
599
|
"Check a background run-code execution: returns its status (queued | running | succeeded | failed | timed_out) and, once finished, its stdout/stderr output. Executions are scoped to the user who started them. While one is queued or running, continue other useful work and poll every ~15-30 seconds instead of busy-waiting.",
|
|
@@ -44,6 +44,15 @@ import {
|
|
|
44
44
|
type ExtensionRow,
|
|
45
45
|
} from "./store.js";
|
|
46
46
|
|
|
47
|
+
// A 200k extension body containing JSON-sensitive HTML/JS characters (quotes,
|
|
48
|
+
// backslashes, and newlines) expands to about 400k characters when pretty-JSON
|
|
49
|
+
// serialized by the agent loop. A history detail can carry the current and
|
|
50
|
+
// previous bodies plus both bodies again in its line diff (about 1.6M chars in
|
|
51
|
+
// the same worst-common-case fixture). These caps add roughly 25% headroom for
|
|
52
|
+
// the surrounding metadata and indentation while still bounding tool context.
|
|
53
|
+
const GET_EXTENSION_MAX_RESULT_CHARS = 500_000;
|
|
54
|
+
const GET_EXTENSION_HISTORY_MAX_RESULT_CHARS = 2_000_000;
|
|
55
|
+
|
|
47
56
|
export function createExtensionActionEntries(): Record<string, ActionEntry> {
|
|
48
57
|
return {
|
|
49
58
|
"list-extensions": {
|
|
@@ -185,6 +194,9 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
|
|
|
185
194
|
),
|
|
186
195
|
};
|
|
187
196
|
},
|
|
197
|
+
// Result is JSON including the full Alpine content; account for JSON
|
|
198
|
+
// escaping and envelope metadata instead of matching the source cap.
|
|
199
|
+
maxResultChars: GET_EXTENSION_MAX_RESULT_CHARS,
|
|
188
200
|
readOnly: true,
|
|
189
201
|
},
|
|
190
202
|
|
|
@@ -276,6 +288,9 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
|
|
|
276
288
|
),
|
|
277
289
|
};
|
|
278
290
|
},
|
|
291
|
+
// With includeContent, history can contain current + previous source and
|
|
292
|
+
// repeat both in the diff, so it needs more headroom than get-extension.
|
|
293
|
+
maxResultChars: GET_EXTENSION_HISTORY_MAX_RESULT_CHARS,
|
|
279
294
|
readOnly: true,
|
|
280
295
|
},
|
|
281
296
|
|
|
@@ -251,17 +251,27 @@ export async function createSsrfSafeDispatcher(): Promise<unknown | null> {
|
|
|
251
251
|
* `httpsOnly` extends the per-hop validation to the URL scheme: redirects are
|
|
252
252
|
* followed only to `https:` targets, so an HTTPS-only caller cannot be
|
|
253
253
|
* downgraded to plain HTTP by a 30x from the (untrusted) origin.
|
|
254
|
+
*
|
|
255
|
+
* `assertUrlAllowed` lets callers layer a stricter destination policy (for
|
|
256
|
+
* example, a credential's origin allowlist) on top of the SSRF checks. It runs
|
|
257
|
+
* before the initial request and before every redirect hop, so sensitive
|
|
258
|
+
* headers and bodies are never forwarded to a destination the caller rejects.
|
|
254
259
|
*/
|
|
255
260
|
export async function ssrfSafeFetch(
|
|
256
261
|
url: string,
|
|
257
262
|
init: RequestInit = {},
|
|
258
|
-
options: {
|
|
263
|
+
options: {
|
|
264
|
+
maxRedirects?: number;
|
|
265
|
+
httpsOnly?: boolean;
|
|
266
|
+
assertUrlAllowed?: (url: string) => void | Promise<void>;
|
|
267
|
+
} = {},
|
|
259
268
|
): Promise<Response> {
|
|
260
269
|
const maxRedirects = options.maxRedirects ?? 3;
|
|
261
270
|
const dispatcher = (await createSsrfSafeDispatcher()) ?? undefined;
|
|
262
271
|
|
|
263
272
|
let currentUrl = url;
|
|
264
273
|
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
274
|
+
await options.assertUrlAllowed?.(currentUrl);
|
|
265
275
|
if (options.httpsOnly && new URL(currentUrl).protocol !== "https:") {
|
|
266
276
|
throw new Error(
|
|
267
277
|
`SSRF blocked: refusing to fetch non-HTTPS address (${currentUrl})`,
|
|
@@ -29,9 +29,10 @@
|
|
|
29
29
|
|
|
30
30
|
import { ssrfSafeFetch } from "../extensions/url-safety.js";
|
|
31
31
|
import {
|
|
32
|
+
getKeyAllowlist,
|
|
33
|
+
getResolvedKeyAllowlist,
|
|
32
34
|
resolveKeyReferencesWithRequestScopes,
|
|
33
35
|
validateUrlAllowlist,
|
|
34
|
-
getKeyAllowlist,
|
|
35
36
|
} from "../secrets/substitution.js";
|
|
36
37
|
import { sendEmail } from "../server/email.js";
|
|
37
38
|
import { registerNotificationChannel } from "./registry.js";
|
|
@@ -72,7 +73,7 @@ function createWebhookChannel(
|
|
|
72
73
|
// No-op when neither a per-notification nor workspace URL is set —
|
|
73
74
|
// mirrors email's empty-recipients behavior so notify-all stays quiet.
|
|
74
75
|
if (!urlTemplate) return false;
|
|
75
|
-
const { url, headers } = await resolveWebhookRequest(
|
|
76
|
+
const { url, headers, assertUrlAllowed } = await resolveWebhookRequest(
|
|
76
77
|
urlTemplate,
|
|
77
78
|
overrideUrlTemplate ? undefined : authTemplate,
|
|
78
79
|
meta.owner,
|
|
@@ -92,7 +93,7 @@ function createWebhookChannel(
|
|
|
92
93
|
emittedAt: new Date().toISOString(),
|
|
93
94
|
}),
|
|
94
95
|
},
|
|
95
|
-
{ maxRedirects: 3 },
|
|
96
|
+
{ maxRedirects: 3, assertUrlAllowed },
|
|
96
97
|
);
|
|
97
98
|
if (!res.ok) {
|
|
98
99
|
throw new Error(
|
|
@@ -121,7 +122,7 @@ function createSlackWebhookChannel(
|
|
|
121
122
|
metadataString(input.metadata, "slackWebhookUrl") ??
|
|
122
123
|
envUrlTemplate?.trim();
|
|
123
124
|
if (!urlTemplate) return false;
|
|
124
|
-
const { url, headers } = await resolveWebhookRequest(
|
|
125
|
+
const { url, headers, assertUrlAllowed } = await resolveWebhookRequest(
|
|
125
126
|
urlTemplate,
|
|
126
127
|
overrideUrlTemplate ? undefined : authTemplate,
|
|
127
128
|
meta.owner,
|
|
@@ -165,7 +166,7 @@ function createSlackWebhookChannel(
|
|
|
165
166
|
],
|
|
166
167
|
}),
|
|
167
168
|
},
|
|
168
|
-
{ maxRedirects: 3 },
|
|
169
|
+
{ maxRedirects: 3, assertUrlAllowed },
|
|
169
170
|
);
|
|
170
171
|
if (!res.ok) {
|
|
171
172
|
throw new Error(
|
|
@@ -214,7 +215,11 @@ async function resolveWebhookRequest(
|
|
|
214
215
|
authTemplate: string | undefined,
|
|
215
216
|
owner: string,
|
|
216
217
|
label: string,
|
|
217
|
-
): Promise<{
|
|
218
|
+
): Promise<{
|
|
219
|
+
url: string;
|
|
220
|
+
headers: Record<string, string>;
|
|
221
|
+
assertUrlAllowed: (url: string) => void;
|
|
222
|
+
}> {
|
|
218
223
|
// Resolve `${keys.NAME}` references through the same request-scope
|
|
219
224
|
// cascade already used by extension fetches (extensions/routes.ts) and
|
|
220
225
|
// automation connector headers (automation/index.ts): user scope first
|
|
@@ -229,46 +234,72 @@ async function resolveWebhookRequest(
|
|
|
229
234
|
// strict superset of the previous user-scope-only behavior.
|
|
230
235
|
// Missing keys throw — the error surfaces in logs and the channel is marked
|
|
231
236
|
// un-delivered, but other channels still run.
|
|
232
|
-
const
|
|
237
|
+
const urlResult = await resolveKeyReferencesWithRequestScopes(
|
|
233
238
|
urlTemplate,
|
|
234
239
|
owner,
|
|
235
240
|
);
|
|
241
|
+
const url = urlResult.resolved;
|
|
236
242
|
const headers: Record<string, string> = {
|
|
237
243
|
"Content-Type": "application/json",
|
|
238
244
|
};
|
|
245
|
+
let authResult:
|
|
246
|
+
| Awaited<ReturnType<typeof resolveKeyReferencesWithRequestScopes>>
|
|
247
|
+
| undefined;
|
|
239
248
|
if (authTemplate) {
|
|
240
|
-
|
|
249
|
+
authResult = await resolveKeyReferencesWithRequestScopes(
|
|
241
250
|
authTemplate,
|
|
242
251
|
owner,
|
|
243
252
|
);
|
|
244
|
-
headers.Authorization =
|
|
253
|
+
headers.Authorization = authResult.resolved;
|
|
245
254
|
}
|
|
246
255
|
|
|
247
256
|
// If the user set an allowlist on a referenced key, enforce it here —
|
|
248
257
|
// origin-level check, same rule the automations fetch-tool applies.
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
keyNames.map((name) => getKeyAllowlist(name, "user", owner)),
|
|
263
|
-
);
|
|
264
|
-
keyNames.forEach((name, i) => {
|
|
265
|
-
if (!validateUrlAllowlist(url, allowlists[i])) {
|
|
266
|
-
throw new Error(
|
|
267
|
-
`[notifications] ${label} URL ${new URL(url).origin} is not in the allowlist for key "${name}"`,
|
|
258
|
+
// Validate URL and Authorization keys independently so an allowlisted auth
|
|
259
|
+
// token cannot be sent to a metadata-provided destination. Keep scope in the
|
|
260
|
+
// dedupe key because the same key name can resolve at a different scope if a
|
|
261
|
+
// credential changes between the two substitutions.
|
|
262
|
+
type ResolvedKey = NonNullable<typeof urlResult.resolvedKeys>[number];
|
|
263
|
+
const keyUsages = new Map<
|
|
264
|
+
string,
|
|
265
|
+
{ name: string; resolvedKey?: ResolvedKey }
|
|
266
|
+
>();
|
|
267
|
+
for (const result of authResult ? [urlResult, authResult] : [urlResult]) {
|
|
268
|
+
for (const name of result.usedKeys) {
|
|
269
|
+
const resolved = (result.resolvedKeys ?? []).filter(
|
|
270
|
+
(ref) => ref.name === name,
|
|
268
271
|
);
|
|
272
|
+
if (resolved.length === 0) {
|
|
273
|
+
keyUsages.set(`user:${owner}:${name}`, { name });
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
for (const resolvedKey of resolved) {
|
|
277
|
+
keyUsages.set(`${resolvedKey.scope}:${resolvedKey.scopeId}:${name}`, {
|
|
278
|
+
name,
|
|
279
|
+
resolvedKey,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
269
282
|
}
|
|
270
|
-
}
|
|
271
|
-
|
|
283
|
+
}
|
|
284
|
+
const usages = Array.from(keyUsages.values());
|
|
285
|
+
const allowlists = await Promise.all(
|
|
286
|
+
usages.map(({ name, resolvedKey }) =>
|
|
287
|
+
resolvedKey
|
|
288
|
+
? getResolvedKeyAllowlist(resolvedKey)
|
|
289
|
+
: getKeyAllowlist(name, "user", owner),
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
const assertUrlAllowed = (candidateUrl: string): void => {
|
|
293
|
+
usages.forEach(({ name }, i) => {
|
|
294
|
+
if (!validateUrlAllowlist(candidateUrl, allowlists[i])) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`[notifications] ${label} URL ${new URL(candidateUrl).origin} is not in the allowlist for key "${name}"`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
assertUrlAllowed(url);
|
|
302
|
+
return { url, headers, assertUrlAllowed };
|
|
272
303
|
}
|
|
273
304
|
|
|
274
305
|
function metadataString(
|
|
@@ -192,6 +192,9 @@ function preserveActionFlags(entry: Record<string, any>): Partial<ActionEntry> {
|
|
|
192
192
|
if (typeof entry.parallelSafe === "boolean") {
|
|
193
193
|
out.parallelSafe = entry.parallelSafe;
|
|
194
194
|
}
|
|
195
|
+
if (typeof entry.dedupe === "boolean") {
|
|
196
|
+
out.dedupe = entry.dedupe;
|
|
197
|
+
}
|
|
195
198
|
if (typeof entry.toolCallable === "boolean") {
|
|
196
199
|
out.toolCallable = entry.toolCallable;
|
|
197
200
|
}
|
|
@@ -716,7 +719,7 @@ export async function mergeCoreSharingActions(
|
|
|
716
719
|
run: def.run,
|
|
717
720
|
...(def.http !== undefined ? { http: def.http } : {}),
|
|
718
721
|
// Carry security-relevant flags (toolCallable, publicAgent, link,
|
|
719
|
-
// mcpApp) plus readOnly/parallelSafe. Without this, the sharing
|
|
722
|
+
// mcpApp) plus readOnly/parallelSafe/dedupe. Without this, the sharing
|
|
720
723
|
// actions' `toolCallable: false` (audit-H5) is dropped and the
|
|
721
724
|
// tools-iframe bridge 403 in action-routes.ts never fires.
|
|
722
725
|
...preserveActionFlags(def),
|
|
@@ -241,6 +241,10 @@ export function createRefreshScreenEntry(): Record<string, ActionEntry> {
|
|
|
241
241
|
// distinct `screen-refresh` poll event. Don't double-emit a generic
|
|
242
242
|
// `action` event on top of that.
|
|
243
243
|
readOnly: true,
|
|
244
|
+
// Refetching volatile on-screen state is the entire point of this
|
|
245
|
+
// tool — an identical repeat call (even with the same scope) is a
|
|
246
|
+
// legitimate re-refresh, not a redundant read to skip.
|
|
247
|
+
dedupe: false,
|
|
244
248
|
tool: {
|
|
245
249
|
description:
|
|
246
250
|
"Manually refresh the user's current screen. The framework ALREADY auto-refreshes after any successful mutating action tool call (template actions and any enabled raw DB write tools) — you do NOT need to call this after a normal action. Use it only when (a) you mutated data via a path the framework can't detect (e.g. a direct write to an external system the app mirrors), or (b) you want to pass a `scope` hint so the UI narrows which queries to refetch. The UI re-fetches its queries without a full page reload.",
|
|
@@ -20,6 +20,12 @@ details live in `.agents/skills/`.
|
|
|
20
20
|
status) and call `list-data-dictionary` with a focused search to learn what
|
|
21
21
|
exists and which table/columns/join paths to use. Don't fan out blind queries
|
|
22
22
|
when the catalog already answers where a fact lives.
|
|
23
|
+
- `data-source-status` always includes the built-in first-party Analytics source
|
|
24
|
+
and returns `hasConnectedExternalDataSources`,
|
|
25
|
+
`dataSourcesSetupLink` (a real deep link to `/data-sources`). Chat stays
|
|
26
|
+
available when no external provider is connected; for a request that needs
|
|
27
|
+
one, explain the missing source in context and include that returned link so
|
|
28
|
+
the user can connect it. Do not replace this with a generic canned response.
|
|
23
29
|
- Simple time-bounded metric fast path: when the data dictionary or a known
|
|
24
30
|
canonical source already identifies the metric, run one bounded aggregate.
|
|
25
31
|
Once that query returns a valid result, answer immediately with the source,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Analytics
|
|
2
2
|
|
|
3
|
-
An open-source, agent-native alternative to Amplitude
|
|
3
|
+
An open-source, agent-native alternative to Amplitude and FullStory. Ask
|
|
4
4
|
analytics questions in plain English and get charts, dashboards, and re-runnable
|
|
5
5
|
investigations back — you own the code, the queries, and the data.
|
|
6
6
|
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
getWorkspaceConnectionProvider,
|
|
4
4
|
listWorkspaceConnectionProvidersForTemplate,
|
|
5
5
|
} from "@agent-native/core/connections";
|
|
6
|
+
import { buildDeepLink } from "@agent-native/core/server";
|
|
6
7
|
import {
|
|
7
8
|
listWorkspaceConnectionGrants,
|
|
8
9
|
listWorkspaceConnections,
|
|
@@ -22,6 +23,11 @@ import { getGitHubAccessToken } from "../server/lib/github-oauth";
|
|
|
22
23
|
import { resolveAnalyticsProviderCredential } from "../server/lib/provider-credentials";
|
|
23
24
|
|
|
24
25
|
const APP_ID = "analytics";
|
|
26
|
+
const DATA_SOURCES_SETUP_LINK = buildDeepLink({
|
|
27
|
+
app: APP_ID,
|
|
28
|
+
view: "data-sources",
|
|
29
|
+
to: "/data-sources",
|
|
30
|
+
});
|
|
25
31
|
|
|
26
32
|
const BUILT_IN_FIRST_PARTY_PROVIDER = {
|
|
27
33
|
provider: "first-party",
|
|
@@ -68,7 +74,7 @@ async function listWorkspaceConnectionsForStatus(): Promise<{
|
|
|
68
74
|
|
|
69
75
|
export default defineAction({
|
|
70
76
|
description:
|
|
71
|
-
"List which analytics data sources are available without revealing secret values. This always includes the built-in first-party Analytics event store, which is queried with `query-agent-native-analytics`; it also reports configured credentials and granted workspace connections. The `key` arg accepts exact credential names like JIRA_API_TOKEN and provider aliases like jira, pylon, bigquery, github, hubspot, gong, or slack.",
|
|
77
|
+
"List which analytics data sources are available without revealing secret values. This always includes the built-in first-party Analytics event store, which is queried with `query-agent-native-analytics`; it also reports configured credentials and granted workspace connections. The result includes `hasConnectedExternalDataSources`, `connectedExternalDataSourceCount`, and `dataSourcesSetupLink`; when a requested provider is unavailable, use that link for contextual setup guidance. The `key` arg accepts exact credential names like JIRA_API_TOKEN and provider aliases like jira, pylon, bigquery, github, hubspot, gong, or slack.",
|
|
72
78
|
schema: z.object({
|
|
73
79
|
key: z
|
|
74
80
|
.string()
|
|
@@ -87,6 +93,7 @@ export default defineAction({
|
|
|
87
93
|
label: "Authentication",
|
|
88
94
|
message: "Sign in to view credential status.",
|
|
89
95
|
settingsPath: "/data-sources",
|
|
96
|
+
dataSourcesSetupLink: DATA_SOURCES_SETUP_LINK,
|
|
90
97
|
};
|
|
91
98
|
}
|
|
92
99
|
|
|
@@ -221,12 +228,18 @@ export default defineAction({
|
|
|
221
228
|
: "credentials",
|
|
222
229
|
})),
|
|
223
230
|
];
|
|
231
|
+
const connectedExternalDataSources = configuredDataSources.filter(
|
|
232
|
+
(source) => source.provider !== BUILT_IN_FIRST_PARTY_PROVIDER.provider,
|
|
233
|
+
);
|
|
224
234
|
return {
|
|
225
235
|
// Keep a compact, explicit summary first so models do not infer source
|
|
226
236
|
// availability from the much larger per-credential list below.
|
|
227
237
|
hasConfiguredDataSources: configuredDataSources.length > 0,
|
|
228
238
|
configuredDataSourceCount: configuredDataSources.length,
|
|
229
239
|
configuredDataSources,
|
|
240
|
+
hasConnectedExternalDataSources: connectedExternalDataSources.length > 0,
|
|
241
|
+
connectedExternalDataSourceCount: connectedExternalDataSources.length,
|
|
242
|
+
dataSourcesSetupLink: DATA_SOURCES_SETUP_LINK,
|
|
230
243
|
credentials: results,
|
|
231
244
|
providers: [BUILT_IN_FIRST_PARTY_PROVIDER, ...providers],
|
|
232
245
|
total: results.length,
|
|
@@ -238,4 +251,9 @@ export default defineAction({
|
|
|
238
251
|
},
|
|
239
252
|
};
|
|
240
253
|
},
|
|
254
|
+
link: () => ({
|
|
255
|
+
url: DATA_SOURCES_SETUP_LINK,
|
|
256
|
+
label: "Open Analytics data sources",
|
|
257
|
+
view: "data-sources",
|
|
258
|
+
}),
|
|
241
259
|
});
|
|
@@ -1147,12 +1147,16 @@ export default function SqlDashboardPage() {
|
|
|
1147
1147
|
}, [dashboard]);
|
|
1148
1148
|
|
|
1149
1149
|
const requestedTab = searchParams.get("tab");
|
|
1150
|
-
const
|
|
1150
|
+
const selectedTab =
|
|
1151
1151
|
tabs.length > 0
|
|
1152
1152
|
? requestedTab && tabs.includes(requestedTab)
|
|
1153
1153
|
? requestedTab
|
|
1154
1154
|
: tabs[0]
|
|
1155
1155
|
: null;
|
|
1156
|
+
// Report captures need the complete dashboard in one image. The report
|
|
1157
|
+
// URL intentionally has no `tab` parameter, so do not apply the normal
|
|
1158
|
+
// first-tab selection while rendering the screenshot surface.
|
|
1159
|
+
const activeTab = reportScreenshot ? null : selectedTab;
|
|
1156
1160
|
const groupedTabs = useMemo(() => groupDashboardTabs(tabs), [tabs]);
|
|
1157
1161
|
const activeTabGroup = activeTab
|
|
1158
1162
|
? groupedTabs.groups.find((group) =>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { redirect, type LoaderFunctionArgs } from "react-router";
|
|
2
2
|
|
|
3
3
|
const SEO_TITLE =
|
|
4
|
-
"Agent-Native Analytics - Open Source
|
|
4
|
+
"Agent-Native Analytics - Open Source Alternative to Amplitude & FullStory";
|
|
5
5
|
const SEO_DESCRIPTION =
|
|
6
|
-
"Open Source analytics app where AI agents connect to warehouses, product analytics, and CRM data to answer questions and build dashboards.";
|
|
6
|
+
"Open Source analytics app and alternative to Amplitude and FullStory where AI agents connect to warehouses, product analytics, and CRM data to answer questions and build dashboards.";
|
|
7
7
|
|
|
8
8
|
export function meta() {
|
|
9
9
|
return [
|
|
@@ -51,6 +51,7 @@ export async function runDashboardReportsOnce(): Promise<{
|
|
|
51
51
|
() =>
|
|
52
52
|
sendDashboardReportSubscription(sub, {
|
|
53
53
|
skipEmailWithoutScreenshot: retryAt !== null,
|
|
54
|
+
allowLimitedFallback: retryAt === null,
|
|
54
55
|
}),
|
|
55
56
|
);
|
|
56
57
|
if (!result.screenshotAttached) {
|
|
@@ -78,7 +79,13 @@ export async function runDashboardReportsOnce(): Promise<{
|
|
|
78
79
|
await markDashboardReportResult(sub, "error", message);
|
|
79
80
|
continue;
|
|
80
81
|
}
|
|
81
|
-
|
|
82
|
+
if (result.screenshotMode !== "full" && result.screenshotError) {
|
|
83
|
+
const detail = `sent with ${result.screenshotMode} screenshot; earlier attempts failed: ${result.screenshotError}`;
|
|
84
|
+
console.warn(`[dashboard-report] Subscription ${sub.id} ${detail}`);
|
|
85
|
+
await markDashboardReportResult(sub, "success", detail);
|
|
86
|
+
} else {
|
|
87
|
+
await markDashboardReportResult(sub, "success");
|
|
88
|
+
}
|
|
82
89
|
} catch (err: any) {
|
|
83
90
|
failed++;
|
|
84
91
|
const message = err?.message ?? String(err);
|
|
@@ -49,13 +49,17 @@ const DASHBOARD_REPORT_CID = "dashboard-report-snapshot";
|
|
|
49
49
|
const LOCAL_SCREENSHOT_TIMEOUT_MS = 90_000;
|
|
50
50
|
const SERVERLESS_SCREENSHOT_TIMEOUT_MS = 90_000;
|
|
51
51
|
const SERVERLESS_SECOND_READY_TIMEOUT_MS = 45_000;
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// Keep enough room under Netlify's 300s background-function limit for the
|
|
53
|
+
// bounded browser-close/profile-cleanup steps after each failed attempt and
|
|
54
|
+
// for the final email send. The three attempts total 225s; cleanup can spend
|
|
55
|
+
// up to 60s, leaving a small delivery buffer.
|
|
56
|
+
const SERVERLESS_FULL_ATTEMPT_TIMEOUT_MS = 110_000;
|
|
57
|
+
const SERVERLESS_LIGHTWEIGHT_ATTEMPT_TIMEOUT_MS = 70_000;
|
|
54
58
|
const BROWSER_CLEANUP_TIMEOUT_MS = 10_000;
|
|
55
59
|
const SCREENSHOT_VIEWPORT_PADDING = 64;
|
|
56
60
|
|
|
57
61
|
type DashboardScreenshotAttempt = {
|
|
58
|
-
label: "full" | "full-lightweight";
|
|
62
|
+
label: "full" | "full-lightweight" | "limited";
|
|
59
63
|
viewport: { width: number; height: number };
|
|
60
64
|
captureScale?: number;
|
|
61
65
|
readyTimeout?: number;
|
|
@@ -770,9 +774,10 @@ function storedAttemptError(message: string): string {
|
|
|
770
774
|
async function captureDashboardPngWithFallback(
|
|
771
775
|
sub: DashboardReportSubscription,
|
|
772
776
|
snapshot: ReportSnapshot,
|
|
777
|
+
options?: { includeLimitedFallback?: boolean },
|
|
773
778
|
): Promise<{
|
|
774
779
|
png: Buffer | null;
|
|
775
|
-
mode: "full" | "full-lightweight" | "none";
|
|
780
|
+
mode: "full" | "full-lightweight" | "limited" | "none";
|
|
776
781
|
error?: string;
|
|
777
782
|
}> {
|
|
778
783
|
const serverless = isServerlessBrowserRuntime();
|
|
@@ -792,7 +797,6 @@ async function captureDashboardPngWithFallback(
|
|
|
792
797
|
label: "full-lightweight",
|
|
793
798
|
viewport: { width: 1200, height: 1400 },
|
|
794
799
|
captureScale: 0.7,
|
|
795
|
-
reportPanelLimit: 8,
|
|
796
800
|
...(serverless
|
|
797
801
|
? {
|
|
798
802
|
readyTimeout: 55_000,
|
|
@@ -802,13 +806,34 @@ async function captureDashboardPngWithFallback(
|
|
|
802
806
|
: {}),
|
|
803
807
|
},
|
|
804
808
|
];
|
|
809
|
+
if (options?.includeLimitedFallback) {
|
|
810
|
+
// Only added on the final sweep after the 1-hour retry window, when the
|
|
811
|
+
// alternative is a no-image fallback email. All three serverless attempt
|
|
812
|
+
// totalTimeouts sum to 225s, leaving room for bounded cleanup and email
|
|
813
|
+
// delivery under the 300s Netlify background-function timeout.
|
|
814
|
+
attempts.push({
|
|
815
|
+
label: "limited",
|
|
816
|
+
viewport: { width: 1200, height: 1400 },
|
|
817
|
+
captureScale: 0.7,
|
|
818
|
+
reportPanelLimit: 8,
|
|
819
|
+
...(serverless
|
|
820
|
+
? {
|
|
821
|
+
readyTimeout: 40_000,
|
|
822
|
+
secondReadyTimeout: 10_000,
|
|
823
|
+
totalTimeout: 45_000,
|
|
824
|
+
}
|
|
825
|
+
: {}),
|
|
826
|
+
});
|
|
827
|
+
}
|
|
805
828
|
const errors: string[] = [];
|
|
806
829
|
|
|
807
830
|
for (const attempt of attempts) {
|
|
808
831
|
try {
|
|
832
|
+
const png = await captureDashboardPng(sub, snapshot, attempt);
|
|
809
833
|
return {
|
|
810
|
-
png
|
|
834
|
+
png,
|
|
811
835
|
mode: attempt.label,
|
|
836
|
+
...(errors.length ? { error: errors.join(" | ") } : {}),
|
|
812
837
|
};
|
|
813
838
|
} catch (err) {
|
|
814
839
|
const attemptError = errorMessage(err);
|
|
@@ -837,7 +862,10 @@ function reportDate(snapshot: ReportSnapshot): string {
|
|
|
837
862
|
|
|
838
863
|
function renderReportEmailHtml(
|
|
839
864
|
snapshot: ReportSnapshot,
|
|
840
|
-
options: {
|
|
865
|
+
options: {
|
|
866
|
+
screenshotAttached: boolean;
|
|
867
|
+
screenshotMode: "full" | "full-lightweight" | "limited" | "none";
|
|
868
|
+
},
|
|
841
869
|
): string {
|
|
842
870
|
const title = escapeHtml(snapshot.title);
|
|
843
871
|
const dashboardUrl = escapeHtml(snapshot.dashboardUrl);
|
|
@@ -850,6 +878,12 @@ function renderReportEmailHtml(
|
|
|
850
878
|
: `<div style="margin:18px 0;padding:14px 16px;border:1px solid #e5e7eb;border-radius:8px;background:#f9fafb;color:#374151;font-size:14px;line-height:1.5;">
|
|
851
879
|
The dashboard image was unavailable for this run. Open the live dashboard to view the latest report.
|
|
852
880
|
</div>`;
|
|
881
|
+
const limitedScreenshotNotice =
|
|
882
|
+
options.screenshotMode === "limited"
|
|
883
|
+
? `<div style="margin:12px 0 0;padding:12px 14px;border:1px solid #f3c46b;border-radius:8px;background:#fff8e6;color:#6b4f14;font-size:13px;line-height:1.45;">
|
|
884
|
+
This is a limited fallback image and may omit some dashboard panels. <a href="${dashboardUrl}" style="color:#2563eb;text-decoration:none;">Open the full dashboard</a> to see every panel.
|
|
885
|
+
</div>`
|
|
886
|
+
: "";
|
|
853
887
|
|
|
854
888
|
return `<!doctype html>
|
|
855
889
|
<html>
|
|
@@ -858,6 +892,7 @@ function renderReportEmailHtml(
|
|
|
858
892
|
Here's your report of <a href="${dashboardUrl}" style="color:#2563eb;text-decoration:none;">${title}</a> for ${date}
|
|
859
893
|
</p>
|
|
860
894
|
${screenshotBlock}
|
|
895
|
+
${limitedScreenshotNotice}
|
|
861
896
|
<p style="margin:18px 0 0;color:#525866;font-size:13px;line-height:1.45;">
|
|
862
897
|
<a href="${dashboardUrl}" style="color:#2563eb;text-decoration:none;">Open dashboard</a>
|
|
863
898
|
<span style="color:#9ca3af;"> · </span>
|
|
@@ -869,7 +904,10 @@ function renderReportEmailHtml(
|
|
|
869
904
|
|
|
870
905
|
function renderReportText(
|
|
871
906
|
snapshot: ReportSnapshot,
|
|
872
|
-
options: {
|
|
907
|
+
options: {
|
|
908
|
+
screenshotAttached: boolean;
|
|
909
|
+
screenshotMode: "full" | "full-lightweight" | "limited" | "none";
|
|
910
|
+
},
|
|
873
911
|
): string {
|
|
874
912
|
const lines = [
|
|
875
913
|
`Daily dashboard report: ${snapshot.title}`,
|
|
@@ -880,6 +918,11 @@ function renderReportText(
|
|
|
880
918
|
if (!options.screenshotAttached) {
|
|
881
919
|
lines.push("Dashboard image unavailable for this run.");
|
|
882
920
|
}
|
|
921
|
+
if (options.screenshotMode === "limited") {
|
|
922
|
+
lines.push(
|
|
923
|
+
`This is a limited fallback image and may omit some dashboard panels. Open the full dashboard: ${snapshot.dashboardUrl}`,
|
|
924
|
+
);
|
|
925
|
+
}
|
|
883
926
|
return lines.join("\n");
|
|
884
927
|
}
|
|
885
928
|
|
|
@@ -897,17 +940,20 @@ export async function sendDashboardReportSubscription(
|
|
|
897
940
|
options: {
|
|
898
941
|
requireScreenshot?: boolean;
|
|
899
942
|
skipEmailWithoutScreenshot?: boolean;
|
|
943
|
+
allowLimitedFallback?: boolean;
|
|
900
944
|
} = {},
|
|
901
945
|
): Promise<{
|
|
902
946
|
dashboardUrl: string;
|
|
903
947
|
recipientCount: number;
|
|
904
948
|
screenshotAttached: boolean;
|
|
905
|
-
screenshotMode: "full" | "full-lightweight" | "none";
|
|
949
|
+
screenshotMode: "full" | "full-lightweight" | "limited" | "none";
|
|
906
950
|
screenshotError?: string;
|
|
907
951
|
emailsSent: boolean;
|
|
908
952
|
}> {
|
|
909
953
|
const snapshot = await collectReportSnapshot(sub);
|
|
910
|
-
const capture = await captureDashboardPngWithFallback(sub, snapshot
|
|
954
|
+
const capture = await captureDashboardPngWithFallback(sub, snapshot, {
|
|
955
|
+
includeLimitedFallback: options?.allowLimitedFallback,
|
|
956
|
+
});
|
|
911
957
|
if (!capture.png && options.requireScreenshot) {
|
|
912
958
|
throw new Error(
|
|
913
959
|
capture.error
|
|
@@ -926,8 +972,14 @@ export async function sendDashboardReportSubscription(
|
|
|
926
972
|
};
|
|
927
973
|
}
|
|
928
974
|
const screenshotAttached = Boolean(capture.png);
|
|
929
|
-
const html = renderReportEmailHtml(snapshot, {
|
|
930
|
-
|
|
975
|
+
const html = renderReportEmailHtml(snapshot, {
|
|
976
|
+
screenshotAttached,
|
|
977
|
+
screenshotMode: capture.mode,
|
|
978
|
+
});
|
|
979
|
+
const text = renderReportText(snapshot, {
|
|
980
|
+
screenshotAttached,
|
|
981
|
+
screenshotMode: capture.mode,
|
|
982
|
+
});
|
|
931
983
|
const subject = `Daily dashboard: ${snapshot.title}`;
|
|
932
984
|
|
|
933
985
|
for (const to of sub.recipients) {
|