experimental-ash 0.25.2 → 0.26.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 (41) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/bin/ash.d.ts +4 -4
  3. package/bin/ash.js +12 -8
  4. package/dist/docs/public/channels/README.md +26 -2
  5. package/dist/docs/public/channels/discord.md +159 -0
  6. package/dist/docs/public/channels/slack.md +14 -2
  7. package/dist/src/chunks/{compile-agent-C4OrJW6C.js → compile-agent-DrIyb818.js} +1 -1
  8. package/dist/src/chunks/{dev-authored-source-watcher-PwAxalwl.js → dev-authored-source-watcher-C1WUVv9F.js} +1 -1
  9. package/dist/src/chunks/{host-F-DkwYJK.js → host-CwAcCrg7.js} +2 -2
  10. package/dist/src/chunks/paths-CWZN-XRX.js +85 -0
  11. package/dist/src/cli/commands/channels.d.ts +15 -0
  12. package/dist/src/cli/commands/channels.js +9 -0
  13. package/dist/src/cli/commands/info.js +1 -1
  14. package/dist/src/cli/run.js +1 -1
  15. package/dist/src/evals/cli/eval.js +1 -1
  16. package/dist/src/internal/application/package.js +1 -1
  17. package/dist/src/internal/process/pnpm.d.ts +28 -0
  18. package/dist/src/internal/process/pnpm.js +50 -0
  19. package/dist/src/public/channels/discord/api.d.ts +99 -0
  20. package/dist/src/public/channels/discord/api.js +167 -0
  21. package/dist/src/public/channels/discord/defaults.d.ts +9 -0
  22. package/dist/src/public/channels/discord/defaults.js +74 -0
  23. package/dist/src/public/channels/discord/discordChannel.d.ts +132 -0
  24. package/dist/src/public/channels/discord/discordChannel.js +402 -0
  25. package/dist/src/public/channels/discord/hitl.d.ts +34 -0
  26. package/dist/src/public/channels/discord/hitl.js +194 -0
  27. package/dist/src/public/channels/discord/inbound.d.ts +97 -0
  28. package/dist/src/public/channels/discord/inbound.js +238 -0
  29. package/dist/src/public/channels/discord/index.d.ts +7 -0
  30. package/dist/src/public/channels/discord/index.js +6 -0
  31. package/dist/src/public/channels/discord/responses.d.ts +11 -0
  32. package/dist/src/public/channels/discord/responses.js +40 -0
  33. package/dist/src/public/channels/discord/verify.d.ts +38 -0
  34. package/dist/src/public/channels/discord/verify.js +72 -0
  35. package/dist/src/public/channels/discord/verifyInbound.d.ts +6 -0
  36. package/dist/src/public/channels/discord/verifyInbound.js +19 -0
  37. package/dist/src/public/channels/slack/constants.d.ts +7 -0
  38. package/dist/src/public/channels/slack/constants.js +7 -0
  39. package/dist/src/public/channels/slack/slackChannel.js +2 -1
  40. package/package.json +9 -3
  41. package/dist/src/chunks/paths-DnlVBqHu.js +0 -85
@@ -1,3 +1,3 @@
1
- import{D as e,n as t}from"../chunks/paths-DnlVBqHu.js";import{createCliTheme as n,renderCliTaggedLine as r}from"./ui/output.js";import{i,n as a,r as o,t as s}from"../chunks/url-JdCGA634.js";async function c(){return(await import(`../chunks/host-F-DkwYJK.js`).then(e=>e.t)).buildApplication}async function l(){return(await import(`./commands/info.js`)).printApplicationInfo}async function u(){return(await import(`./dev/repl.js`)).runDevelopmentRepl}async function d(){return(await import(`../evals/cli/eval.js`)).runEvalCommand}async function f(){return(await import(`../chunks/host-F-DkwYJK.js`).then(e=>e.t)).startDevelopmentServer}function p(e){return e.name()===`info`||e.name()===`dev`}async function m(e){await new Promise((t,n)=>{let r=!1,i=()=>{process.off(`SIGINT`,a),process.off(`SIGTERM`,a)},a=()=>{r||(r=!0,i(),e.close().then(t,n))};process.once(`SIGINT`,a),process.once(`SIGTERM`,a)})}function h(e){if(!/^-?\d+$/.test(e))throw new i(`Expected a numeric port, received "${e}".`);let t=Number(e);if(t<0||t>65535)throw new i(`Expected a port between 0 and 65535, received "${e}".`);return t}function g(){return!!(process.stdin.isTTY&&process.stdout.isTTY)}function _(e){let t=e[1];return e[0]!==`dev`||e.length!==2||t===void 0||t.startsWith(`-`)?[...e]:[`dev`,`--url`,t]}function v(e){if(e.url){if(e.host!==void 0)throw new i(`The --host option cannot be used with --url.`);if(e.port!==void 0)throw new i(`The --port option cannot be used with --url.`);if(e.repl===!1)throw new i(`The --no-repl option cannot be used with --url.`);return e.url}}function y(i,o){let _=t(),y=e().version,b=new a,x=n();return b.name(`ash`).description(`Build and run an Ash application.`).version(y).showHelpAfterError().exitOverride().hook(`preAction`,(e,t)=>{p(t)&&i.log(`Ash (v${y})`)}).configureOutput({writeErr:e=>{i.error(e.trimEnd())},writeOut:e=>{i.log(e.trimEnd())}}),b.command(`build`).description(`Build the current Ash application.`).action(async()=>{let{loadDevelopmentEnvironmentFiles:e}=await import(`./dev/environment.js`);e(_);let t=await(o.buildHost??await c())(_);i.log(r(x,{message:`built output at ${t}`,tag:`build`,tone:`success`}))}),b.command(`dev`).description(`Start the Ash development server or connect the REPL to an existing URL.`).option(`--host <host>`,`Host interface to bind`).option(`--no-repl`,`Start the server without the interactive REPL`).option(`--port <port>`,`Port to listen on (defaults to $PORT, then 3000)`,h).option(`--schedules`,`Run scheduled tasks during development (off by default)`).option(`-u, --url <url>`,`Connect the REPL to an existing server URL`,s).addHelpText(`after`,`
1
+ import{D as e,n as t}from"../chunks/paths-CWZN-XRX.js";import{createCliTheme as n,renderCliTaggedLine as r}from"./ui/output.js";import{i,n as a,r as o,t as s}from"../chunks/url-JdCGA634.js";async function c(){return(await import(`../chunks/host-CwAcCrg7.js`).then(e=>e.t)).buildApplication}async function l(){return(await import(`./commands/info.js`)).printApplicationInfo}async function u(){return(await import(`./dev/repl.js`)).runDevelopmentRepl}async function d(){return(await import(`../evals/cli/eval.js`)).runEvalCommand}async function f(){return(await import(`../chunks/host-CwAcCrg7.js`).then(e=>e.t)).startDevelopmentServer}function p(e){return e.name()===`info`||e.name()===`dev`}async function m(e){await new Promise((t,n)=>{let r=!1,i=()=>{process.off(`SIGINT`,a),process.off(`SIGTERM`,a)},a=()=>{r||(r=!0,i(),e.close().then(t,n))};process.once(`SIGINT`,a),process.once(`SIGTERM`,a)})}function h(e){if(!/^-?\d+$/.test(e))throw new i(`Expected a numeric port, received "${e}".`);let t=Number(e);if(t<0||t>65535)throw new i(`Expected a port between 0 and 65535, received "${e}".`);return t}function g(){return!!(process.stdin.isTTY&&process.stdout.isTTY)}function _(e){let t=e[1];return e[0]!==`dev`||e.length!==2||t===void 0||t.startsWith(`-`)?[...e]:[`dev`,`--url`,t]}function v(e){if(e.url){if(e.host!==void 0)throw new i(`The --host option cannot be used with --url.`);if(e.port!==void 0)throw new i(`The --port option cannot be used with --url.`);if(e.repl===!1)throw new i(`The --no-repl option cannot be used with --url.`);return e.url}}function y(i,o){let _=t(),y=e().version,b=new a,x=n();b.name(`ash`).description(`Build and run an Ash application.`).version(y).showHelpAfterError().exitOverride().hook(`preAction`,(e,t)=>{p(t)&&i.log(`Ash (v${y})`)}).configureOutput({writeErr:e=>{i.error(e.trimEnd())},writeOut:e=>{i.log(e.trimEnd())}});let S=b.command(`channels`).description(`Manage user-authored channels in the current project.`);return S.command(`add [kind]`).description(`Scaffold a channel (slack) into the current project.`).option(`-f, --force`,`Overwrite existing channel files`).action(async(e,t)=>{let{runChannelsAddCommand:n}=await import(`./commands/channels.js`);await n(i,_,{kind:e,options:t})}),S.command(`list`).description(`List user-authored channels in the current project.`).option(`--json`,`Output as JSON`).action(async e=>{let{runChannelsListCommand:t}=await import(`./commands/channels.js`);await t(i,_,e)}),b.command(`build`).description(`Build the current Ash application.`).action(async()=>{let{loadDevelopmentEnvironmentFiles:e}=await import(`./dev/environment.js`);e(_);let t=await(o.buildHost??await c())(_);i.log(r(x,{message:`built output at ${t}`,tag:`build`,tone:`success`}))}),b.command(`dev`).description(`Start the Ash development server or connect the REPL to an existing URL.`).option(`--host <host>`,`Host interface to bind`).option(`--no-repl`,`Start the server without the interactive REPL`).option(`--port <port>`,`Port to listen on (defaults to $PORT, then 3000)`,h).option(`--schedules`,`Run scheduled tasks during development (off by default)`).option(`-u, --url <url>`,`Connect the REPL to an existing server URL`,s).addHelpText(`after`,`
2
2
  You can also pass a bare URL as the only argument, for example: ash dev https://example.com
3
3
  `).action(async e=>{let t=v(e),{loadDevelopmentEnvironmentFiles:n}=await import(`./dev/environment.js`);if(n(_),t){if(i.log(r(x,{message:`REPL connecting to ${t}`,tag:`dev`,tone:`info`})),!g()){i.log(r(x,{message:`Interactive REPL disabled because the current terminal is not a TTY.`,tag:`dev`,tone:`warning`}));return}i.log(``),await(o.runDevelopmentRepl??await u())({serverUrl:t});return}let a=await(o.startHost??await f())(_,{host:e.host,port:e.port,schedules:e.schedules===!0}),s=!1,c=async()=>{s||(s=!0,await a.close())};try{if(i.log(r(x,{message:`server listening at ${a.url}`,tag:`dev`,tone:`success`})),e.repl===!1)return await m({close:c});if(!g())return i.log(r(x,{message:`Interactive REPL disabled because the current terminal is not a TTY.`,tag:`dev`,tone:`warning`})),await m({close:c});i.log(``),await(o.runDevelopmentRepl??await u())({serverUrl:a.url})}finally{await c()}}),b.command(`info`).description(`Print resolved application information.`).action(async()=>{await(o.printApplicationInfo??await l())(i,_)}),b.command(`eval`).description(`Run eval suites against an Ash agent.`).option(`--suite <id...>`,`Suite IDs to run (repeatable)`).option(`--all`,`Run all discovered suites`).option(`--url <url>`,`Remote agent URL (skip local host startup)`).option(`--timeout <ms>`,`Per-case timeout in milliseconds`).option(`--max-concurrency <n>`,`Max concurrent case executions per suite`).option(`--json`,`Output results as JSON`).option(`--skip-report`,`Skip suite-defined reporters (e.g. Braintrust)`).action(async e=>{await(o.runEvalCommand??await d())(e,i)}),b}async function b(e=process.argv.slice(2),t=console,n={}){let r=y(t,n),i=e.length===0?[`info`]:_(e);try{await r.parseAsync(i,{from:`user`})}catch(e){if(e instanceof o){if(e.exitCode===0)return;throw Error(e.message)}throw e}}export{b as runCli};
@@ -1 +1 @@
1
- import{n as e}from"../../chunks/paths-DnlVBqHu.js";import{loadDevelopmentEnvironmentFiles as t}from"../../cli/dev/environment.js";import{n,s as r,t as i}from"../../chunks/client-ZqNLLMZB.js";import{n as a}from"../../chunks/host-F-DkwYJK.js";import{discoverAndImportSuites as o}from"../runner/discover.js";import{executeSuite as s}from"../runner/execute-suite.js";import{ConsoleReporter as c}from"../runner/reporters/console.js";var l=r();async function u(n,r){let i=e();t(i);let c=n.suite,l=await o(i,c);if(l.length===0){c&&c.length>0?r.error(`No suites found matching: ${c.join(`, `)}`):r.error(`No eval suites found. Create suite files under evals/ with the *.eval.ts extension.`),process.exitCode=1;return}let u,f;n.url?f={kind:`remote`,url:n.url}:(u=await a(i,{host:`127.0.0.1`,port:0}),f={kind:`local`,url:u.url});let p=d(f);try{let e=[];for(let t of l){let r=m(t,n),a=h(r,{json:n.json===!0,skipReport:n.skipReport===!0}),o=await s({suite:r,target:f,reporters:a,appRoot:i,client:p});e.push(o)}n.json&&r.log(JSON.stringify(e,null,2)),e.some(e=>e.errored>0)&&(process.exitCode=1)}finally{u&&await u.close()}process.exit(process.exitCode??0)}function d(e){if(e.kind===`local`)return new i({host:e.url});let t={},r=process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();return r&&(t[n]=r),new i({auth:f(),headers:Object.keys(t).length>0?t:void 0,host:e.url})}function f(){let e=process.env.ASH_EVAL_AUTH_TOKEN?.trim();return e?{bearer:e}:{bearer:p}}async function p(){try{let e=(await(0,l.getVercelOidcToken)()).trim();if(e.length>0)return e}catch{}return process.env.VERCEL_OIDC_TOKEN?.trim()??``}function m(e,t){let n=t.maxConcurrency?Number.parseInt(t.maxConcurrency,10):void 0,r=t.timeout?Number.parseInt(t.timeout,10):void 0;if(n===void 0&&r===void 0)return e;let i={...e};return n!==void 0&&(i.maxConcurrency=n),r!==void 0&&(i.timeoutMs=r),i}function h(e,t){let n=t.json?[]:[new c];return!t.skipReport&&e.reporters&&n.push(...e.reporters),n}export{u as runEvalCommand};
1
+ import{n as e}from"../../chunks/paths-CWZN-XRX.js";import{loadDevelopmentEnvironmentFiles as t}from"../../cli/dev/environment.js";import{n,s as r,t as i}from"../../chunks/client-ZqNLLMZB.js";import{n as a}from"../../chunks/host-CwAcCrg7.js";import{discoverAndImportSuites as o}from"../runner/discover.js";import{executeSuite as s}from"../runner/execute-suite.js";import{ConsoleReporter as c}from"../runner/reporters/console.js";var l=r();async function u(n,r){let i=e();t(i);let c=n.suite,l=await o(i,c);if(l.length===0){c&&c.length>0?r.error(`No suites found matching: ${c.join(`, `)}`):r.error(`No eval suites found. Create suite files under evals/ with the *.eval.ts extension.`),process.exitCode=1;return}let u,f;n.url?f={kind:`remote`,url:n.url}:(u=await a(i,{host:`127.0.0.1`,port:0}),f={kind:`local`,url:u.url});let p=d(f);try{let e=[];for(let t of l){let r=m(t,n),a=h(r,{json:n.json===!0,skipReport:n.skipReport===!0}),o=await s({suite:r,target:f,reporters:a,appRoot:i,client:p});e.push(o)}n.json&&r.log(JSON.stringify(e,null,2)),e.some(e=>e.errored>0)&&(process.exitCode=1)}finally{u&&await u.close()}process.exit(process.exitCode??0)}function d(e){if(e.kind===`local`)return new i({host:e.url});let t={},r=process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();return r&&(t[n]=r),new i({auth:f(),headers:Object.keys(t).length>0?t:void 0,host:e.url})}function f(){let e=process.env.ASH_EVAL_AUTH_TOKEN?.trim();return e?{bearer:e}:{bearer:p}}async function p(){try{let e=(await(0,l.getVercelOidcToken)()).trim();if(e.length>0)return e}catch{}return process.env.VERCEL_OIDC_TOKEN?.trim()??``}function m(e,t){let n=t.maxConcurrency?Number.parseInt(t.maxConcurrency,10):void 0,r=t.timeout?Number.parseInt(t.timeout,10):void 0;if(n===void 0&&r===void 0)return e;let i={...e};return n!==void 0&&(i.maxConcurrency=n),r!==void 0&&(i.timeoutMs=r),i}function h(e,t){let n=t.json?[]:[new c];return!t.skipReport&&e.reporters&&n.push(...e.reporters),n}export{u as runEvalCommand};
@@ -6,7 +6,7 @@ import { ASH_PACKAGE_NAME } from "#internal/package-name.js";
6
6
  let cachedPackageInfo;
7
7
  // The package build stamps the published version into `dist` so bundled
8
8
  // deployments can still report package metadata without resolving package.json.
9
- const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.25.2";
9
+ const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.26.0";
10
10
  const BUNDLED_FALLBACK_PACKAGE_VERSION_PLACEHOLDER = "__ASH_PACKAGE_VERSION_PLACEHOLDER__";
11
11
  const WORKFLOW_MODULE_ALIASES = {
12
12
  "workflow/api": "src/compiled/@workflow/core/runtime.js",
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Resolved invocation for the host's pnpm executable. The shape carries
3
+ * everything `child_process.spawn` and `execFile` need to dispatch to the
4
+ * right binary across macOS/Linux PATH installs, Corepack-managed shims,
5
+ * and Windows runners that surface pnpm only through `PNPM_HOME`.
6
+ */
7
+ export interface PnpmInvocation {
8
+ readonly args: readonly string[];
9
+ readonly command: string;
10
+ readonly shell?: boolean;
11
+ }
12
+ /**
13
+ * Picks the right pnpm executable for the current host. Resolution order:
14
+ *
15
+ * 1. `PNPM_HOME` — the standard install location used by Corepack and the
16
+ * pnpm installers. On Windows, points at `pnpm.CMD` because the bare
17
+ * `pnpm` shim is not directly invokable from a non-shell spawn.
18
+ * 2. `npm_execpath` — set when the current process was launched by an
19
+ * npm-compatible package manager. Pointing at a `.cjs`/`.js` entry
20
+ * means we have to run it through `node` (typical for Corepack
21
+ * shims); otherwise treat it as a bare executable path.
22
+ * 3. Bare `pnpm` on PATH — the macOS/Linux happy path.
23
+ *
24
+ * Pure: no side effects, returns the invocation shape; the caller picks
25
+ * `spawn` vs. `execFile`. The test harness uses this to keep platform
26
+ * handling in one place.
27
+ */
28
+ export declare function resolvePnpmInvocation(args: readonly string[]): PnpmInvocation;
@@ -0,0 +1,50 @@
1
+ import { existsSync } from "node:fs";
2
+ import { extname, join } from "node:path";
3
+ /**
4
+ * Picks the right pnpm executable for the current host. Resolution order:
5
+ *
6
+ * 1. `PNPM_HOME` — the standard install location used by Corepack and the
7
+ * pnpm installers. On Windows, points at `pnpm.CMD` because the bare
8
+ * `pnpm` shim is not directly invokable from a non-shell spawn.
9
+ * 2. `npm_execpath` — set when the current process was launched by an
10
+ * npm-compatible package manager. Pointing at a `.cjs`/`.js` entry
11
+ * means we have to run it through `node` (typical for Corepack
12
+ * shims); otherwise treat it as a bare executable path.
13
+ * 3. Bare `pnpm` on PATH — the macOS/Linux happy path.
14
+ *
15
+ * Pure: no side effects, returns the invocation shape; the caller picks
16
+ * `spawn` vs. `execFile`. The test harness uses this to keep platform
17
+ * handling in one place.
18
+ */
19
+ export function resolvePnpmInvocation(args) {
20
+ const pnpmHome = process.env.PNPM_HOME;
21
+ if (pnpmHome !== undefined) {
22
+ const command = join(pnpmHome, process.platform === "win32" ? "pnpm.CMD" : "pnpm");
23
+ if (existsSync(command)) {
24
+ return {
25
+ args,
26
+ command,
27
+ shell: process.platform === "win32",
28
+ };
29
+ }
30
+ }
31
+ const npmExecPath = process.env.npm_execpath;
32
+ if (npmExecPath !== undefined && npmExecPath.toLowerCase().includes("pnpm")) {
33
+ const extension = extname(npmExecPath).toLowerCase();
34
+ if (extension === ".cjs" || extension === ".js") {
35
+ return {
36
+ args: [npmExecPath, ...args],
37
+ command: process.execPath,
38
+ };
39
+ }
40
+ return {
41
+ args,
42
+ command: npmExecPath,
43
+ shell: process.platform === "win32",
44
+ };
45
+ }
46
+ return {
47
+ args,
48
+ command: "pnpm",
49
+ };
50
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Minimal Discord REST API wrapper used by the Discord channel.
3
+ *
4
+ * The channel talks directly to Discord's JSON HTTP API instead of
5
+ * exposing a third-party SDK through Ash public surfaces.
6
+ */
7
+ import { type JsonObject } from "#shared/json.js";
8
+ import { resolveDiscordPublicKey, type DiscordPublicKey } from "#public/channels/discord/verify.js";
9
+ /** Discord application id, materialized directly or from an async secret provider. */
10
+ export type DiscordApplicationId = string | (() => string | Promise<string>);
11
+ /** Discord bot token, materialized directly or from an async secret provider. */
12
+ export type DiscordBotToken = string | (() => string | Promise<string>);
13
+ /** Fetch implementation override used by tests or non-standard runtimes. */
14
+ export type DiscordFetch = typeof fetch;
15
+ /** Credentials used by the native Discord channel. */
16
+ export interface DiscordCredentials {
17
+ readonly applicationId?: DiscordApplicationId;
18
+ readonly botToken?: DiscordBotToken;
19
+ readonly publicKey?: DiscordPublicKey;
20
+ }
21
+ /** Shared Discord API options. */
22
+ export interface DiscordApiOptions {
23
+ readonly apiBaseUrl?: string;
24
+ readonly credentials?: DiscordCredentials;
25
+ readonly fetch?: DiscordFetch;
26
+ }
27
+ /** Raw Discord API response body. */
28
+ export interface DiscordApiResponse {
29
+ readonly status: number;
30
+ readonly ok: boolean;
31
+ readonly body: unknown;
32
+ }
33
+ /** Minimal Discord message object returned by channel write operations. */
34
+ export interface DiscordPostedMessage {
35
+ /** Discord message id, when Discord returned one. */
36
+ readonly id: string;
37
+ /** Channel id associated with the message, when Discord returned one. */
38
+ readonly channelId?: string;
39
+ /** Discord's raw JSON response. */
40
+ readonly raw: unknown;
41
+ }
42
+ /** Allowed mentions payload that suppresses all generated pings. */
43
+ export declare const DISCORD_NO_MENTIONS: JsonObject;
44
+ /** Discord's documented message-content cap. */
45
+ export declare const DISCORD_MESSAGE_CONTENT_MAX_LENGTH = 2000;
46
+ /** Builds the channel-local continuation token (`<channelId>:<conversationId>`). */
47
+ export declare function discordContinuationToken(channelId: string, conversationId: string | undefined): string;
48
+ /** Resolves a Discord application id, falling back to `DISCORD_APPLICATION_ID`. */
49
+ export declare function resolveDiscordApplicationId(applicationId?: DiscordApplicationId): Promise<string>;
50
+ /** Resolves a Discord bot token, falling back to `DISCORD_BOT_TOKEN`. */
51
+ export declare function resolveDiscordBotToken(botToken?: DiscordBotToken): Promise<string>;
52
+ /** Resolves a Discord public key, falling back to `DISCORD_PUBLIC_KEY`. */
53
+ export { resolveDiscordPublicKey };
54
+ /**
55
+ * Low-level Discord JSON API call. Bot-token auth is included only when
56
+ * a token is supplied; interaction webhook endpoints intentionally run
57
+ * without bot auth.
58
+ */
59
+ export declare function callDiscordApi(input: {
60
+ readonly apiBaseUrl?: string;
61
+ readonly body?: JsonObject;
62
+ readonly botToken?: DiscordBotToken;
63
+ readonly fetch?: DiscordFetch;
64
+ readonly method?: "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
65
+ readonly path: string;
66
+ }): Promise<DiscordApiResponse>;
67
+ /** Sends a bot-authenticated message to one Discord channel. */
68
+ export declare function sendDiscordChannelMessage(input: DiscordApiOptions & {
69
+ readonly body: DiscordMessageBody;
70
+ readonly channelId: string;
71
+ }): Promise<DiscordPostedMessage>;
72
+ /** Triggers Discord's short-lived channel typing indicator with bot auth. */
73
+ export declare function triggerDiscordTypingIndicator(input: DiscordApiOptions & {
74
+ readonly channelId: string;
75
+ }): Promise<void>;
76
+ /** Edits the original response for a deferred Discord interaction. */
77
+ export declare function editDiscordOriginalResponse(input: DiscordApiOptions & {
78
+ readonly body: DiscordMessageBody;
79
+ readonly interactionToken: string;
80
+ }): Promise<DiscordPostedMessage>;
81
+ /** Creates a Discord interaction followup message. */
82
+ export declare function createDiscordFollowupMessage(input: DiscordApiOptions & {
83
+ readonly body: DiscordMessageBody;
84
+ readonly interactionToken: string;
85
+ }): Promise<DiscordPostedMessage>;
86
+ /** JSON body supported by Discord message endpoints used by Ash. */
87
+ export interface DiscordMessageBody {
88
+ readonly allowed_mentions?: Readonly<Record<string, unknown>>;
89
+ readonly components?: readonly Readonly<Record<string, unknown>>[];
90
+ readonly content?: string;
91
+ readonly flags?: number;
92
+ readonly tts?: boolean;
93
+ }
94
+ /**
95
+ * Splits text into chunks Discord will accept as individual message
96
+ * contents. Empty strings produce one empty chunk so callers can still
97
+ * decide how to handle a no-content message.
98
+ */
99
+ export declare function splitDiscordMessageContent(content: string): readonly string[];
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Minimal Discord REST API wrapper used by the Discord channel.
3
+ *
4
+ * The channel talks directly to Discord's JSON HTTP API instead of
5
+ * exposing a third-party SDK through Ash public surfaces.
6
+ */
7
+ import { parseJsonObject } from "#shared/json.js";
8
+ import { isObject } from "#shared/guards.js";
9
+ import { resolveDiscordPublicKey } from "#public/channels/discord/verify.js";
10
+ /** Allowed mentions payload that suppresses all generated pings. */
11
+ export const DISCORD_NO_MENTIONS = { parse: [] };
12
+ /** Discord's documented message-content cap. */
13
+ export const DISCORD_MESSAGE_CONTENT_MAX_LENGTH = 2000;
14
+ /** Builds the channel-local continuation token (`<channelId>:<conversationId>`). */
15
+ export function discordContinuationToken(channelId, conversationId) {
16
+ return `${channelId}:${conversationId ?? ""}`;
17
+ }
18
+ /** Resolves a Discord application id, falling back to `DISCORD_APPLICATION_ID`. */
19
+ export async function resolveDiscordApplicationId(applicationId) {
20
+ const source = applicationId ?? process.env.DISCORD_APPLICATION_ID;
21
+ if (!source)
22
+ throw new Error("DISCORD_APPLICATION_ID is required.");
23
+ return typeof source === "function" ? await source() : source;
24
+ }
25
+ /** Resolves a Discord bot token, falling back to `DISCORD_BOT_TOKEN`. */
26
+ export async function resolveDiscordBotToken(botToken) {
27
+ const source = botToken ?? process.env.DISCORD_BOT_TOKEN;
28
+ if (!source)
29
+ throw new Error("DISCORD_BOT_TOKEN is required.");
30
+ return typeof source === "function" ? await source() : source;
31
+ }
32
+ /** Resolves a Discord public key, falling back to `DISCORD_PUBLIC_KEY`. */
33
+ export { resolveDiscordPublicKey };
34
+ /**
35
+ * Low-level Discord JSON API call. Bot-token auth is included only when
36
+ * a token is supplied; interaction webhook endpoints intentionally run
37
+ * without bot auth.
38
+ */
39
+ export async function callDiscordApi(input) {
40
+ const apiFetch = input.fetch ?? fetch;
41
+ const headers = new Headers();
42
+ headers.set("content-type", "application/json; charset=utf-8");
43
+ if (input.botToken !== undefined) {
44
+ const token = await resolveDiscordBotToken(input.botToken);
45
+ headers.set("authorization", `Bot ${token}`);
46
+ }
47
+ const init = {
48
+ headers,
49
+ method: input.method ?? "POST",
50
+ };
51
+ if (input.body !== undefined) {
52
+ init.body = JSON.stringify(parseJsonObject(input.body));
53
+ }
54
+ const response = await apiFetch(`${input.apiBaseUrl ?? "https://discord.com/api/v10"}${input.path}`, init);
55
+ return {
56
+ body: await parseResponseBody(response),
57
+ ok: response.ok,
58
+ status: response.status,
59
+ };
60
+ }
61
+ /** Sends a bot-authenticated message to one Discord channel. */
62
+ export async function sendDiscordChannelMessage(input) {
63
+ const response = await callDiscordApi({
64
+ apiBaseUrl: input.apiBaseUrl,
65
+ body: normalizeMessageBody(input.body),
66
+ botToken: input.credentials?.botToken,
67
+ fetch: input.fetch,
68
+ path: `/channels/${encodeURIComponent(input.channelId)}/messages`,
69
+ });
70
+ if (!response.ok) {
71
+ throw new Error(`Discord create message failed with HTTP ${response.status}.`);
72
+ }
73
+ return toPostedMessage(response.body);
74
+ }
75
+ /** Triggers Discord's short-lived channel typing indicator with bot auth. */
76
+ export async function triggerDiscordTypingIndicator(input) {
77
+ const response = await callDiscordApi({
78
+ apiBaseUrl: input.apiBaseUrl,
79
+ botToken: input.credentials?.botToken,
80
+ fetch: input.fetch,
81
+ path: `/channels/${encodeURIComponent(input.channelId)}/typing`,
82
+ });
83
+ if (!response.ok) {
84
+ throw new Error(`Discord typing indicator failed with HTTP ${response.status}.`);
85
+ }
86
+ }
87
+ /** Edits the original response for a deferred Discord interaction. */
88
+ export async function editDiscordOriginalResponse(input) {
89
+ const applicationId = await resolveDiscordApplicationId(input.credentials?.applicationId);
90
+ const response = await callDiscordApi({
91
+ apiBaseUrl: input.apiBaseUrl,
92
+ body: normalizeMessageBody(input.body),
93
+ fetch: input.fetch,
94
+ method: "PATCH",
95
+ path: `/webhooks/${encodeURIComponent(applicationId)}/${encodeURIComponent(input.interactionToken)}/messages/@original`,
96
+ });
97
+ if (!response.ok) {
98
+ throw new Error(`Discord edit original response failed with HTTP ${response.status}.`);
99
+ }
100
+ return toPostedMessage(response.body);
101
+ }
102
+ /** Creates a Discord interaction followup message. */
103
+ export async function createDiscordFollowupMessage(input) {
104
+ const applicationId = await resolveDiscordApplicationId(input.credentials?.applicationId);
105
+ const response = await callDiscordApi({
106
+ apiBaseUrl: input.apiBaseUrl,
107
+ body: normalizeMessageBody(input.body),
108
+ fetch: input.fetch,
109
+ path: `/webhooks/${encodeURIComponent(applicationId)}/${encodeURIComponent(input.interactionToken)}`,
110
+ });
111
+ if (!response.ok) {
112
+ throw new Error(`Discord followup message failed with HTTP ${response.status}.`);
113
+ }
114
+ return toPostedMessage(response.body);
115
+ }
116
+ /**
117
+ * Splits text into chunks Discord will accept as individual message
118
+ * contents. Empty strings produce one empty chunk so callers can still
119
+ * decide how to handle a no-content message.
120
+ */
121
+ export function splitDiscordMessageContent(content) {
122
+ if (content.length <= DISCORD_MESSAGE_CONTENT_MAX_LENGTH)
123
+ return [content];
124
+ const chunks = [];
125
+ let rest = content;
126
+ while (rest.length > DISCORD_MESSAGE_CONTENT_MAX_LENGTH) {
127
+ let cut = rest.lastIndexOf("\n", DISCORD_MESSAGE_CONTENT_MAX_LENGTH);
128
+ if (cut <= 0) {
129
+ cut = rest.lastIndexOf(" ", DISCORD_MESSAGE_CONTENT_MAX_LENGTH);
130
+ }
131
+ if (cut <= 0)
132
+ cut = DISCORD_MESSAGE_CONTENT_MAX_LENGTH;
133
+ chunks.push(rest.slice(0, cut).trimEnd());
134
+ rest = rest.slice(cut).trimStart();
135
+ }
136
+ chunks.push(rest);
137
+ return chunks;
138
+ }
139
+ function normalizeMessageBody(body) {
140
+ const normalized = { ...body };
141
+ if (normalized.allowed_mentions === undefined) {
142
+ normalized.allowed_mentions = DISCORD_NO_MENTIONS;
143
+ }
144
+ return parseJsonObject(normalized);
145
+ }
146
+ function toPostedMessage(body) {
147
+ const raw = parseMaybeObject(body);
148
+ return {
149
+ channelId: typeof raw.channel_id === "string" ? raw.channel_id : undefined,
150
+ id: typeof raw.id === "string" ? raw.id : "",
151
+ raw: body,
152
+ };
153
+ }
154
+ function parseMaybeObject(value) {
155
+ return isObject(value) ? value : {};
156
+ }
157
+ async function parseResponseBody(response) {
158
+ const text = await response.text();
159
+ if (!text)
160
+ return null;
161
+ try {
162
+ return JSON.parse(text);
163
+ }
164
+ catch {
165
+ return text;
166
+ }
167
+ }
@@ -0,0 +1,9 @@
1
+ import type { SessionAuthContext } from "#channel/types.js";
2
+ import type { DiscordCommandInteraction } from "#public/channels/discord/inbound.js";
3
+ import type { DiscordChannelEvents, DiscordCommandResult, DiscordContext } from "#public/channels/discord/discordChannel.js";
4
+ /** Default auth projection for Discord interaction actors. */
5
+ export declare function defaultDiscordAuth(interaction: DiscordCommandInteraction): SessionAuthContext;
6
+ /** Default command hook: dispatch with Discord user auth. */
7
+ export declare function defaultOnCommand(_ctx: DiscordContext, interaction: DiscordCommandInteraction): DiscordCommandResult;
8
+ /** Built-in Discord event handlers for typing, replies, HITL, and terminal errors. */
9
+ export declare const defaultEvents: DiscordChannelEvents;
@@ -0,0 +1,74 @@
1
+ import { extractErrorId, formatErrorHint } from "#internal/logging.js";
2
+ import { splitDiscordMessageContent } from "#public/channels/discord/api.js";
3
+ import { renderInputRequestComponents } from "#public/channels/discord/hitl.js";
4
+ /** Default auth projection for Discord interaction actors. */
5
+ export function defaultDiscordAuth(interaction) {
6
+ const attributes = {
7
+ channel_id: interaction.channelId,
8
+ interaction_id: interaction.id,
9
+ user_id: interaction.user.id,
10
+ username: interaction.user.username,
11
+ };
12
+ if (interaction.guildId !== undefined)
13
+ attributes.guild_id = interaction.guildId;
14
+ if (interaction.member?.nick !== undefined)
15
+ attributes.member_nick = interaction.member.nick;
16
+ const issuer = interaction.guildId ? `discord:${interaction.guildId}` : "discord";
17
+ const principalId = interaction.guildId
18
+ ? `discord:${interaction.guildId}:${interaction.user.id}`
19
+ : `discord:${interaction.user.id}`;
20
+ return {
21
+ attributes,
22
+ authenticator: "discord-interaction",
23
+ issuer,
24
+ principalId,
25
+ principalType: interaction.user.isBot ? "service" : "user",
26
+ };
27
+ }
28
+ /** Default command hook: dispatch with Discord user auth. */
29
+ export function defaultOnCommand(_ctx, interaction) {
30
+ return { auth: defaultDiscordAuth(interaction) };
31
+ }
32
+ /** Built-in Discord event handlers for typing, replies, HITL, and terminal errors. */
33
+ export const defaultEvents = {
34
+ async "turn.started"(_event, ctx) {
35
+ await ctx.discord.startTyping();
36
+ },
37
+ async "actions.requested"(_event, ctx) {
38
+ await ctx.discord.startTyping();
39
+ },
40
+ async "input.requested"(event, ctx) {
41
+ for (const request of event.requests) {
42
+ const content = splitDiscordMessageContent(request.prompt)[0] ?? request.prompt;
43
+ await ctx.discord.post({
44
+ components: renderInputRequestComponents(request),
45
+ content,
46
+ });
47
+ }
48
+ },
49
+ async "message.completed"(event, ctx) {
50
+ if (event.finishReason === "tool-calls" || !event.message)
51
+ return;
52
+ await ctx.discord.post(event.message);
53
+ },
54
+ async "session.failed"(event, ctx) {
55
+ const hint = formatErrorHint(event);
56
+ const errorId = extractErrorId(event.details);
57
+ await ctx.discord.post([
58
+ `This session could not recover from an error${hint}.`,
59
+ "",
60
+ "Start a new command to continue.",
61
+ ...(errorId ? ["", `Error id: ${errorId}`] : []),
62
+ ].join("\n"));
63
+ },
64
+ async "turn.failed"(event, ctx) {
65
+ const hint = formatErrorHint(event);
66
+ const errorId = extractErrorId(event.details);
67
+ await ctx.discord.post([
68
+ `I hit an error while handling your request${hint}.`,
69
+ "",
70
+ "Please try again, rephrase, or reach out if it keeps failing.",
71
+ ...(errorId ? ["", `Error id: ${errorId}`] : []),
72
+ ].join("\n"));
73
+ },
74
+ };
@@ -0,0 +1,132 @@
1
+ import type { ModelMessage } from "ai";
2
+ import type { TypedReceiveRoute } from "#channel/receive-args.js";
3
+ import type { SessionHandle } from "#channel/session.js";
4
+ import type { SessionAuthContext } from "#channel/types.js";
5
+ import type { HandleMessageStreamEvent } from "#protocol/message.js";
6
+ import { type DiscordApiOptions, type DiscordApiResponse, type DiscordCredentials, type DiscordMessageBody, type DiscordPostedMessage } from "#public/channels/discord/api.js";
7
+ import { type DiscordCommandInteraction } from "#public/channels/discord/inbound.js";
8
+ import { type DiscordWebhookVerifier } from "#public/channels/discord/verify.js";
9
+ import { type JsonObject } from "#shared/json.js";
10
+ import { type Channel } from "#public/definitions/defineChannel.js";
11
+ type EventData<T extends HandleMessageStreamEvent["type"]> = Extract<HandleMessageStreamEvent, {
12
+ type: T;
13
+ }> extends {
14
+ data: infer D;
15
+ } ? D : undefined;
16
+ /** Pre-dispatch Discord context passed to inbound command hooks. */
17
+ export interface DiscordContext {
18
+ readonly discord: DiscordHandle;
19
+ }
20
+ /** Event-handler Discord context, including mutable per-conversation state. */
21
+ export interface DiscordEventContext extends DiscordContext {
22
+ readonly session: SessionHandle;
23
+ state: DiscordChannelState;
24
+ }
25
+ /** JSON-serializable Discord channel state. */
26
+ export interface DiscordChannelState {
27
+ /** Discord channel id. */
28
+ channelId: string | null;
29
+ /** Discord message id once anchored, or an interaction placeholder before the first reply. */
30
+ conversationId: string | null;
31
+ /** Discord guild id, when the interaction was invoked in a guild. */
32
+ guildId: string | null;
33
+ /** Discord application id from the inbound interaction. */
34
+ applicationId: string | null;
35
+ /** Latest interaction token available to the channel. */
36
+ interactionToken: string | null;
37
+ /** Whether the deferred original interaction response has already been edited. */
38
+ initialResponseSent: boolean;
39
+ /** Whether `conversationId` is a real Discord message id. */
40
+ hasMessageAnchor: boolean;
41
+ }
42
+ /** Discord channel credentials. */
43
+ export interface DiscordChannelCredentials extends DiscordCredentials {
44
+ /**
45
+ * Custom inbound webhook verifier. When supplied, Ash skips the
46
+ * `DISCORD_PUBLIC_KEY` fallback and delegates verification to this
47
+ * function.
48
+ */
49
+ readonly webhookVerifier?: DiscordWebhookVerifier;
50
+ }
51
+ /** Arguments accepted by `receive(discord, args)` for proactive sessions. */
52
+ export interface DiscordReceiveArgs {
53
+ readonly channelId: string;
54
+ readonly conversationId?: string;
55
+ readonly initialMessage?: string | DiscordMessageBody;
56
+ }
57
+ /** Result of an inbound Discord command hook. Return `null` to acknowledge without dispatching. */
58
+ export type DiscordCommandResult = {
59
+ readonly auth: SessionAuthContext | null;
60
+ readonly ephemeral?: boolean;
61
+ readonly modelContext?: readonly ModelMessage[];
62
+ } | null;
63
+ /** Sync or async {@link DiscordCommandResult}. */
64
+ export type DiscordCommandResultOrPromise = DiscordCommandResult | Promise<DiscordCommandResult>;
65
+ type DiscordEventHandler<T extends HandleMessageStreamEvent["type"]> = (data: EventData<T>, ctx: DiscordEventContext) => void | Promise<void>;
66
+ /** Event handlers supported by `discordChannel({ events })`. */
67
+ export interface DiscordChannelEvents {
68
+ readonly "turn.started"?: DiscordEventHandler<"turn.started">;
69
+ readonly "actions.requested"?: DiscordEventHandler<"actions.requested">;
70
+ readonly "action.result"?: DiscordEventHandler<"action.result">;
71
+ readonly "message.completed"?: DiscordEventHandler<"message.completed">;
72
+ readonly "message.appended"?: DiscordEventHandler<"message.appended">;
73
+ readonly "input.requested"?: DiscordEventHandler<"input.requested">;
74
+ readonly "turn.failed"?: DiscordEventHandler<"turn.failed">;
75
+ readonly "turn.completed"?: DiscordEventHandler<"turn.completed">;
76
+ readonly "session.failed"?: DiscordEventHandler<"session.failed">;
77
+ readonly "session.completed"?: DiscordEventHandler<"session.completed">;
78
+ readonly "session.waiting"?: DiscordEventHandler<"session.waiting">;
79
+ readonly "connection.authorization_required"?: DiscordEventHandler<"connection.authorization_required">;
80
+ readonly "connection.authorization_pending"?: DiscordEventHandler<"connection.authorization_pending">;
81
+ readonly "connection.authorization_completed"?: DiscordEventHandler<"connection.authorization_completed">;
82
+ }
83
+ /** Configuration for {@link discordChannel}. */
84
+ export interface DiscordChannelConfig {
85
+ readonly api?: Omit<DiscordApiOptions, "credentials">;
86
+ readonly credentials?: DiscordChannelCredentials;
87
+ /** Override the default interaction route path (`/ash/v1/discord`). */
88
+ readonly route?: string;
89
+ /**
90
+ * Inbound command hook. Defaults to user-scoped Discord auth and dispatch.
91
+ * Return `{ auth }` to dispatch, or `null` to acknowledge without running
92
+ * the agent.
93
+ */
94
+ onCommand?(ctx: DiscordContext, interaction: DiscordCommandInteraction): DiscordCommandResultOrPromise;
95
+ readonly events?: DiscordChannelEvents;
96
+ }
97
+ /** Low-level Discord handle exposed to hooks and event handlers. */
98
+ export interface DiscordHandle {
99
+ /** Discord application id when known. */
100
+ readonly applicationId: string | undefined;
101
+ /** Discord channel id. */
102
+ readonly channelId: string;
103
+ /** Current Ash conversation id, usually the Discord anchor message id. */
104
+ readonly conversationId: string;
105
+ /** Discord guild id when known. */
106
+ readonly guildId: string | undefined;
107
+ /** Latest Discord interaction token when known. */
108
+ readonly interactionToken: string | undefined;
109
+ /** Raw Discord API escape hatch. */
110
+ request(path: string, body: JsonObject, options?: DiscordRequestOptions): Promise<DiscordApiResponse>;
111
+ /** Posts a message to the current Discord conversation. */
112
+ post(message: string | DiscordMessageBody): Promise<DiscordPostedMessage>;
113
+ /** Sends a bot-authenticated message to this Discord channel. */
114
+ sendChannelMessage(message: string | DiscordMessageBody): Promise<DiscordPostedMessage>;
115
+ /** Edits the deferred original interaction response. */
116
+ editOriginalResponse(message: string | DiscordMessageBody): Promise<DiscordPostedMessage>;
117
+ /** Creates an interaction followup message. */
118
+ followup(message: string | DiscordMessageBody): Promise<DiscordPostedMessage>;
119
+ /** Triggers Discord's short-lived typing indicator. Failures are swallowed. */
120
+ startTyping(): Promise<void>;
121
+ }
122
+ /** Options for {@link DiscordHandle.request}. */
123
+ export interface DiscordRequestOptions {
124
+ readonly botAuth?: boolean;
125
+ readonly method?: "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
126
+ }
127
+ /** Concrete return type of {@link discordChannel}. */
128
+ export interface DiscordChannel extends Channel<DiscordChannelState>, TypedReceiveRoute<DiscordReceiveArgs> {
129
+ }
130
+ /** Discord channel factory for HTTP Interactions and proactive channel messages. */
131
+ export declare function discordChannel(config?: DiscordChannelConfig): DiscordChannel;
132
+ export {};