@aspalagin/openclaw-max 0.7.2 → 0.8.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 (83) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel.js +16 -0
  18. package/dist/src/command-menu.js +199 -0
  19. package/dist/src/config-schema.js +106 -10
  20. package/dist/src/deliver.js +144 -48
  21. package/dist/src/dispatch.js +12 -11
  22. package/dist/src/gateway-drain.js +40 -0
  23. package/dist/src/group-mention.js +129 -0
  24. package/dist/src/inbound-attachments.js +162 -20
  25. package/dist/src/inbound.js +388 -134
  26. package/dist/src/media-temp.js +31 -20
  27. package/dist/src/network.js +216 -0
  28. package/dist/src/onboarding.js +4 -2
  29. package/dist/src/polling.js +180 -12
  30. package/dist/src/presentation.js +11 -0
  31. package/dist/src/progress-draft.js +168 -0
  32. package/dist/src/runtime.js +15 -0
  33. package/dist/src/secret-contract.js +149 -0
  34. package/dist/src/send.js +250 -46
  35. package/dist/src/stream-draft.js +83 -41
  36. package/dist/src/turn-adoption.js +24 -0
  37. package/dist/src/typing.js +55 -0
  38. package/dist/src/update-journal.js +267 -0
  39. package/dist/src/webhook-queue.js +151 -26
  40. package/dist/src/webhook-runner.js +61 -9
  41. package/dist/src/webhook.js +34 -15
  42. package/openclaw.plugin.json +986 -13
  43. package/package.json +25 -5
  44. package/src/access-policy.ts +154 -0
  45. package/src/accounts.ts +260 -25
  46. package/src/action-scope.ts +274 -0
  47. package/src/actions.ts +188 -35
  48. package/src/api.ts +158 -101
  49. package/src/callbacks.ts +26 -7
  50. package/src/channel-agent-prompt.ts +8 -19
  51. package/src/channel-config.ts +32 -3
  52. package/src/channel-lifecycle.ts +33 -6
  53. package/src/channel-outbound.ts +50 -7
  54. package/src/channel.ts +16 -0
  55. package/src/command-menu.ts +277 -0
  56. package/src/config-schema.ts +111 -10
  57. package/src/deliver.ts +174 -55
  58. package/src/dispatch.ts +12 -11
  59. package/src/gateway-drain.ts +45 -0
  60. package/src/group-mention.ts +191 -0
  61. package/src/inbound-attachments.ts +173 -22
  62. package/src/inbound.ts +443 -130
  63. package/src/media-temp.ts +35 -23
  64. package/src/network.ts +233 -0
  65. package/src/onboarding.ts +4 -2
  66. package/src/polling.ts +221 -15
  67. package/src/presentation.ts +14 -1
  68. package/src/progress-draft.ts +226 -0
  69. package/src/runtime.ts +23 -0
  70. package/src/secret-contract.ts +183 -0
  71. package/src/send.ts +346 -59
  72. package/src/stream-draft.ts +102 -41
  73. package/src/turn-adoption.ts +33 -0
  74. package/src/types.ts +9 -3
  75. package/src/typing.ts +77 -0
  76. package/src/update-journal.ts +302 -0
  77. package/src/webhook-queue.ts +205 -33
  78. package/src/webhook-runner.ts +65 -8
  79. package/src/webhook.ts +39 -18
  80. package/scripts/check-cycles.mjs +0 -38
  81. package/scripts/test-api.mjs +0 -169
  82. package/scripts/test-send.mjs +0 -162
  83. package/scripts/update-schema.mjs +0 -48
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Turn status (streaming.mode "progress"): one MAX message that core's
3
+ * progress compositor keeps up to date while the agent works — status
4
+ * headline, plan, approvals and, with streaming.progress.toolProgress, a
5
+ * rolling tool log — and that is deleted once the final answer landed.
6
+ *
7
+ * Core owns what the status says (createChannelProgressDraftCompositor, the
8
+ * channels.max.streaming.progress settings) and when the preview may go away
9
+ * (createLivePreviewLifecycle: deleted after a delivered final, kept after
10
+ * an error final or a failed delivery). This module is the MAX transport:
11
+ * send once, then edit in place, at most one call per second
12
+ * (createDraftStreamLoop) — half of MAX's two edits per second per chat.
13
+ */
14
+
15
+ import type { ChannelLogSink } from 'openclaw/plugin-sdk/channel-contract';
16
+ import {
17
+ createChannelProgressDraftCompositor,
18
+ createDraftStreamLoop,
19
+ createLivePreviewLifecycle,
20
+ } from 'openclaw/plugin-sdk/channel-outbound';
21
+
22
+ import type { ResolvedMaxAccount } from './accounts.js';
23
+ import { deleteMaxMessage, editMaxMessage, resolveMaxSendFlags, sendMaxMessage } from './send.js';
24
+
25
+ /** One send or edit per second for the status message. */
26
+ export const MAX_PROGRESS_THROTTLE_MS = 1000;
27
+ /** MAX text limit; the compositor stays far below it (8 lines × 120 chars). */
28
+ const MAX_PROGRESS_MAX_CHARS = 4000;
29
+
30
+ type Compositor = ReturnType<typeof createChannelProgressDraftCompositor>;
31
+
32
+ /** Progress callbacks of the reply dispatch (GetReplyOptions subset). */
33
+ export interface MaxProgressReplyOptions {
34
+ suppressDefaultToolProgressMessages: true;
35
+ preserveProgressCallbackStartOrder: true;
36
+ onVerboseProgressVisibility: (isActive: () => boolean) => void;
37
+ onToolStart: (payload: Parameters<Compositor['pushToolEvent']>[0]) => Promise<boolean>;
38
+ onItemEvent: (payload: Parameters<Compositor['pushItemEvent']>[0]) => Promise<boolean>;
39
+ onPlanUpdate: (payload: {
40
+ phase?: string;
41
+ explanation?: string;
42
+ explanationFormat?: 'plain';
43
+ steps?: Parameters<Compositor['pushPlanProgress']>[0];
44
+ }) => Promise<boolean>;
45
+ onApprovalEvent: (payload: Parameters<Compositor['pushApprovalEvent']>[0]) => Promise<boolean>;
46
+ onAssistantMessageStart: () => boolean;
47
+ }
48
+
49
+ export interface MaxProgressDraft {
50
+ readonly replyOptions: MaxProgressReplyOptions;
51
+ /** Mid of the status message while it is shown. */
52
+ readonly messageId: string | undefined;
53
+ /**
54
+ * Deliver a final answer: status updates stop, `send` delivers it, and the
55
+ * status message is deleted once a non-error final landed. Errors of `send`
56
+ * propagate (the status then stays as the record of the failed turn).
57
+ */
58
+ deliverFinal(params: { isError: boolean; send: () => Promise<void> }): Promise<void>;
59
+ /**
60
+ * End of the turn: stop timers and updates; an unused status message (no
61
+ * final, e.g. NO_REPLY or an answer sent by the message tool) is deleted
62
+ * unless the turn failed.
63
+ */
64
+ close(params?: { failed?: boolean }): Promise<void>;
65
+ }
66
+
67
+ export function createMaxProgressDraft(params: {
68
+ account: ResolvedMaxAccount;
69
+ chatId: string;
70
+ replyToId?: string;
71
+ log?: ChannelLogSink;
72
+ statusSink?: (patch: { lastOutboundAt?: number }) => void;
73
+ }): MaxProgressDraft {
74
+ const { account, chatId, log, statusSink } = params;
75
+ let messageId: string | undefined;
76
+ let stopped = false;
77
+ let verboseActive: () => boolean = () => false;
78
+
79
+ const sendOrEdit = async (text: string): Promise<boolean> => {
80
+ const body =
81
+ text.length > MAX_PROGRESS_MAX_CHARS ? text.slice(0, MAX_PROGRESS_MAX_CHARS) : text;
82
+ if (!messageId) {
83
+ // No status appears once the final answer is on its way.
84
+ if (lifecycle.finalStarted) return false;
85
+ // Plain text (tool names and paths carry markdown characters); no push
86
+ // notification for a temporary message — the final answer notifies.
87
+ const res = await sendMaxMessage(chatId, body, {
88
+ token: account.token,
89
+ replyToMessageId: params.replyToId,
90
+ ...resolveMaxSendFlags(account.config, { silent: true }),
91
+ });
92
+ messageId = res.messageId || undefined;
93
+ statusSink?.({ lastOutboundAt: Date.now() });
94
+ return Boolean(messageId);
95
+ }
96
+ await editMaxMessage(messageId, body, { token: account.token });
97
+ return true;
98
+ };
99
+
100
+ const stopOnError = (err: unknown) => {
101
+ // A failed status never fails the turn: stop updating and keep going.
102
+ stopped = true;
103
+ log?.debug?.(`[${account.accountId}] MAX progress status failed: ${String(err)}`);
104
+ };
105
+
106
+ const loop = createDraftStreamLoop<string>({
107
+ throttleMs: MAX_PROGRESS_THROTTLE_MS,
108
+ coalesceInFlight: true,
109
+ isStopped: () => stopped,
110
+ sendOrEditStreamMessage: sendOrEdit,
111
+ onBackgroundFlushError: stopOnError,
112
+ });
113
+
114
+ const stopUpdates = async () => {
115
+ stopped = true;
116
+ loop.stop();
117
+ await loop.waitForInFlight().catch(() => undefined);
118
+ };
119
+
120
+ const lifecycle = createLivePreviewLifecycle<void, string>({
121
+ draft: {
122
+ flush: () => loop.flush().catch(stopOnError),
123
+ id: () => messageId,
124
+ discardPending: stopUpdates,
125
+ clear: async () => {
126
+ if (!messageId) return true;
127
+ await deleteMaxMessage(messageId, { token: account.token });
128
+ messageId = undefined;
129
+ return true;
130
+ },
131
+ },
132
+ // Like Discord: an error final keeps the status as the record of the turn.
133
+ retainOnError: true,
134
+ cleanupUndelivered: true,
135
+ onFinalStarted: () => {
136
+ compositor.markFinalReplyStarted();
137
+ loop.resetPending();
138
+ },
139
+ onFinalDelivered: () => compositor.markFinalReplyDelivered(),
140
+ onCleanupFailure: (err) => {
141
+ log?.warn(`[${account.accountId}] MAX progress status delete failed: ${String(err)}`);
142
+ },
143
+ });
144
+
145
+ const compositor = createChannelProgressDraftCompositor({
146
+ preparedItems: true,
147
+ entry: account.config as Parameters<typeof createChannelProgressDraftCompositor>[0]['entry'],
148
+ mode: 'progress',
149
+ active: true,
150
+ seed: `${account.accountId}:${chatId}`,
151
+ update: async (text, options) => {
152
+ if (stopped) return false;
153
+ loop.update(text);
154
+ if (options.flush) await loop.flush().catch(stopOnError);
155
+ return !stopped;
156
+ },
157
+ // Every line was retracted: remove the message; new progress sends a new one.
158
+ deleteCurrent: async () => {
159
+ loop.resetPending();
160
+ await loop.waitForInFlight().catch(() => undefined);
161
+ const current = messageId;
162
+ if (!current) return;
163
+ messageId = undefined;
164
+ await deleteMaxMessage(current, { token: account.token }).catch((err: unknown) => {
165
+ log?.warn(`[${account.accountId}] MAX progress status delete failed: ${String(err)}`);
166
+ });
167
+ },
168
+ });
169
+
170
+ /** Progress may change until the final answer starts; verbose mode owns it otherwise. */
171
+ const canPush = () => !stopped && !lifecycle.finalStarted && !verboseActive();
172
+ const push = async (event: () => Promise<boolean>): Promise<boolean> => {
173
+ if (!canPush()) return false;
174
+ try {
175
+ return await event();
176
+ } catch (err) {
177
+ stopOnError(err);
178
+ return false;
179
+ }
180
+ };
181
+
182
+ return {
183
+ get messageId() {
184
+ return messageId;
185
+ },
186
+ replyOptions: {
187
+ suppressDefaultToolProgressMessages: true,
188
+ preserveProgressCallbackStartOrder: true,
189
+ onVerboseProgressVisibility: (isActive) => {
190
+ verboseActive = isActive;
191
+ },
192
+ onToolStart: (payload) => push(() => compositor.pushToolEvent(payload)),
193
+ onItemEvent: (payload) => push(() => compositor.pushItemEvent(payload)),
194
+ onPlanUpdate: (payload) =>
195
+ payload.phase === 'update'
196
+ ? push(() =>
197
+ compositor.pushPlanProgress(payload.steps, {
198
+ explanation: payload.explanation,
199
+ explanationFormat: payload.explanationFormat,
200
+ }),
201
+ )
202
+ : Promise.resolve(false),
203
+ onApprovalEvent: (payload) => push(() => compositor.pushApprovalEvent(payload)),
204
+ onAssistantMessageStart: () => {
205
+ compositor.beginAssistantMessage();
206
+ return false;
207
+ },
208
+ },
209
+ deliverFinal: async ({ isError, send }) => {
210
+ await lifecycle.deliver({
211
+ kind: 'final',
212
+ payload: undefined,
213
+ isError,
214
+ deliverNormally: async () => {
215
+ await send();
216
+ return { visibleReplySent: true };
217
+ },
218
+ });
219
+ },
220
+ close: async (options) => {
221
+ compositor.cancel();
222
+ await lifecycle.cleanup({ failed: options?.failed === true });
223
+ await stopUpdates();
224
+ },
225
+ };
226
+ }
package/src/runtime.ts CHANGED
@@ -13,6 +13,29 @@ export function getMaxRuntime(): PluginRuntime {
13
13
  return runtime;
14
14
  }
15
15
 
16
+ /** The log levels plugin modules without an account log sink use. */
17
+ export type MaxLogger = {
18
+ info: (message: string) => void;
19
+ warn: (message: string) => void;
20
+ error: (message: string) => void;
21
+ };
22
+
23
+ let pluginLogger: { owner: PluginRuntime; logger: MaxLogger } | undefined;
24
+
25
+ /**
26
+ * Logger for modules with no account log sink at hand (API client, send
27
+ * helpers): the plugin's child logger of the gateway runtime, or the console
28
+ * before the runtime is set (CLI, tests).
29
+ */
30
+ export function getMaxLogger(): MaxLogger {
31
+ const logging = runtime?.logging;
32
+ if (!runtime || typeof logging?.getChildLogger !== 'function') return console;
33
+ if (pluginLogger?.owner !== runtime) {
34
+ pluginLogger = { owner: runtime, logger: logging.getChildLogger({ plugin: 'max' }) };
35
+ }
36
+ return pluginLogger.logger;
37
+ }
38
+
16
39
  /**
17
40
  * Legacy runtime config API (OpenClaw < 2026.9.3).
18
41
  * Since 2026.9.3 `loadConfig`/`writeConfigFile` are removed from PluginRuntime;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * SecretRef contract of the MAX channel. `secretTargetRegistryEntries` tells
3
+ * `openclaw secrets configure/apply/audit` which options may hold a SecretRef;
4
+ * `collectRuntimeConfigAssignments` tells the gateway which of them each
5
+ * account uses, so it resolves them into the runtime snapshot before the
6
+ * account starts and isolates an account whose ref does not resolve (owner
7
+ * `max:<accountId>`). Loaded from `secret-contract-api.js` before the plugin
8
+ * runtime: keep this module free of runtime imports.
9
+ */
10
+
11
+ import {
12
+ collectSecretInputAssignment,
13
+ createChannelSecretTargetRegistryEntries,
14
+ getChannelRecord,
15
+ hasOwnProperty,
16
+ isEnabledFlag,
17
+ isRecord,
18
+ type ResolverContext,
19
+ type SecretDefaults,
20
+ } from 'openclaw/plugin-sdk/channel-secret-basic-runtime';
21
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from 'openclaw/plugin-sdk/core';
22
+
23
+ /** Options accepting a SecretRef, at the channel level and under accounts.<id>. */
24
+ export const MAX_SECRET_FIELDS = ['botToken', 'webhookSecret', 'httpProxy'] as const;
25
+ export type MaxSecretField = (typeof MAX_SECRET_FIELDS)[number];
26
+
27
+ export const secretTargetRegistryEntries = createChannelSecretTargetRegistryEntries({
28
+ channelKey: 'max',
29
+ account: [...MAX_SECRET_FIELDS],
30
+ channel: [...MAX_SECRET_FIELDS],
31
+ });
32
+
33
+ interface MaxAccountEntry {
34
+ /** Config key under channels.max.accounts */
35
+ key: string;
36
+ accountId: string;
37
+ account: Record<string, unknown>;
38
+ enabled: boolean;
39
+ }
40
+
41
+ /** Webhook mode as resolveMaxTransport decides it (transport, else webhookUrl). */
42
+ function usesWebhook(config: Record<string, unknown>): boolean {
43
+ if (config.transport === 'polling' || config.transport === 'webhook') {
44
+ return config.transport === 'webhook';
45
+ }
46
+ return typeof config.webhookUrl === 'string' && config.webhookUrl.trim().length > 0;
47
+ }
48
+
49
+ /**
50
+ * Whether an account reads the option at all: the token always, the webhook
51
+ * secret only in webhook mode, the proxy always. `config` holds the account's
52
+ * own transport/webhookUrl (both are per-bot keys, never inherited).
53
+ */
54
+ function usesField(field: MaxSecretField, config: Record<string, unknown>): boolean {
55
+ return field === 'webhookSecret' ? usesWebhook(config) : true;
56
+ }
57
+
58
+ /** The default account exists as listMaxAccountIds decides it. */
59
+ function hasDefaultAccount(
60
+ channel: Record<string, unknown>,
61
+ named: MaxAccountEntry[],
62
+ env: NodeJS.ProcessEnv,
63
+ ): boolean {
64
+ if (channel.botToken || channel.tokenFile || env.MAX_BOT_TOKEN) return true;
65
+ return named.length === 0 && channel.enabled !== false;
66
+ }
67
+
68
+ function listNamedAccounts(channel: Record<string, unknown>): MaxAccountEntry[] {
69
+ const accounts = channel.accounts;
70
+ if (!isRecord(accounts)) return [];
71
+ const entries: MaxAccountEntry[] = [];
72
+ for (const [key, account] of Object.entries(accounts)) {
73
+ if (!isRecord(account)) continue;
74
+ const accountId = normalizeAccountId(key);
75
+ if (accountId === DEFAULT_ACCOUNT_ID) continue;
76
+ entries.push({
77
+ key,
78
+ accountId,
79
+ account,
80
+ enabled: isEnabledFlag(channel) && isEnabledFlag(account),
81
+ });
82
+ }
83
+ return entries;
84
+ }
85
+
86
+ /**
87
+ * Register every SecretRef the enabled MAX accounts read. A channel-level
88
+ * value belongs to the default account and, for httpProxy only, to each named
89
+ * account that does not set its own; the other options are per-bot.
90
+ */
91
+ export function collectRuntimeConfigAssignments(params: {
92
+ config: { channels?: Record<string, unknown> };
93
+ defaults?: SecretDefaults;
94
+ context: ResolverContext;
95
+ }): void {
96
+ const channel = getChannelRecord(params.config, 'max');
97
+ if (!channel) return;
98
+ // The owner contract: every non-secret channel setting the accounts use.
99
+ const channelDefaults = Object.fromEntries(
100
+ Object.entries(channel).filter(([key]) => key !== 'accounts'),
101
+ );
102
+ const named = listNamedAccounts(channel);
103
+ const defaultActive =
104
+ isEnabledFlag(channel) && hasDefaultAccount(channel, named, params.context.env);
105
+
106
+ for (const field of MAX_SECRET_FIELDS) {
107
+ const consumers: Array<{ accountId: string; account: Record<string, unknown> }> = [];
108
+ if (defaultActive && usesField(field, channel)) {
109
+ consumers.push({ accountId: DEFAULT_ACCOUNT_ID, account: {} });
110
+ }
111
+ if (field === 'httpProxy') {
112
+ for (const entry of named) {
113
+ if (entry.enabled && !hasOwnProperty(entry.account, field)) consumers.push(entry);
114
+ }
115
+ }
116
+ const applyChannel = (value: unknown) => {
117
+ channel[field] = value;
118
+ };
119
+ const channelPath = `channels.max.${field}`;
120
+ if (consumers.length === 0) {
121
+ collectSecretInputAssignment({
122
+ value: channel[field],
123
+ path: channelPath,
124
+ expected: 'string',
125
+ defaults: params.defaults,
126
+ context: params.context,
127
+ active: false,
128
+ inactiveReason: `no enabled MAX account uses this channel-level ${field}.`,
129
+ apply: applyChannel,
130
+ });
131
+ }
132
+ const contract = { channel: channelDefaults, consumers: consumers.map((c) => c.accountId) };
133
+ for (const consumer of consumers) {
134
+ collectSecretInputAssignment({
135
+ value: channel[field],
136
+ path: channelPath,
137
+ expected: 'string',
138
+ defaults: params.defaults,
139
+ context: params.context,
140
+ owner: {
141
+ ownerKind: 'account',
142
+ ownerId: `max:${consumer.accountId}`,
143
+ requiredForGateway: false,
144
+ disposition: 'isolate',
145
+ contract,
146
+ },
147
+ apply: applyChannel,
148
+ });
149
+ }
150
+
151
+ for (const entry of named) {
152
+ if (!hasOwnProperty(entry.account, field)) continue;
153
+ collectSecretInputAssignment({
154
+ value: entry.account[field],
155
+ path: `channels.max.accounts.${entry.key}.${field}`,
156
+ expected: 'string',
157
+ defaults: params.defaults,
158
+ context: params.context,
159
+ active: entry.enabled && usesField(field, entry.account),
160
+ inactiveReason:
161
+ field === 'webhookSecret'
162
+ ? 'MAX account is disabled or does not use webhook mode.'
163
+ : 'MAX account is disabled.',
164
+ owner: {
165
+ ownerKind: 'account',
166
+ ownerId: `max:${entry.accountId}`,
167
+ requiredForGateway: false,
168
+ disposition: 'isolate',
169
+ contract: { channel: channelDefaults, account: entry.account },
170
+ },
171
+ apply: (value) => {
172
+ entry.account[field] = value;
173
+ },
174
+ });
175
+ }
176
+ }
177
+ }
178
+
179
+ /** Channel secrets adapter (ChannelPlugin.secrets) and secret-contract-api.js export. */
180
+ export const maxChannelSecrets = {
181
+ secretTargetRegistryEntries,
182
+ collectRuntimeConfigAssignments,
183
+ };