discoclaw 0.5.3 → 0.5.5
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/.env.example.full +5 -2
- package/dist/config.js +3 -0
- package/dist/config.test.js +13 -1
- package/dist/discord/actions.js +1 -1
- package/dist/discord/actions.test.js +1 -1
- package/dist/discord/deferred-runner.js +6 -3
- package/dist/discord/deferred-runner.test.js +78 -0
- package/dist/discord/update-command.js +2 -1
- package/dist/index.js +1 -0
- package/dist/npm-managed.js +1 -0
- package/dist/npm-managed.test.js +1 -0
- package/package.json +1 -1
package/.env.example.full
CHANGED
|
@@ -192,14 +192,17 @@ DISCORD_GUILD_ID=
|
|
|
192
192
|
#DISCOCLAW_DISCORD_ACTIONS_BOT_PROFILE=1
|
|
193
193
|
|
|
194
194
|
# Allow the AI to defer another invocation (e.g., "check on the forge run in 10 minutes").
|
|
195
|
-
# The scheduler enforces DISCOCLAW_DISCORD_ACTIONS=1
|
|
196
|
-
# permissions
|
|
195
|
+
# The scheduler enforces DISCOCLAW_DISCORD_ACTIONS=1 and respects the requesting message's
|
|
196
|
+
# channel permissions. Nested defers are allowed up to DEFER_MAX_DEPTH (default: 4).
|
|
197
197
|
DISCOCLAW_DISCORD_ACTIONS_DEFER=1
|
|
198
198
|
# Optional guard rails keep deferred invocations within safe bounds.
|
|
199
199
|
# Maximum delay (seconds) the scheduler will accept for defer commands (default: 1800 / 30 min).
|
|
200
200
|
#DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS=1800
|
|
201
201
|
# Maximum number of pending deferred invocations allowed at once (default: 5).
|
|
202
202
|
#DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT=5
|
|
203
|
+
# Maximum nesting depth for deferred runs. Depth 0 = original message, depth 1 = first defer, etc.
|
|
204
|
+
# When depth reaches this limit, the defer action is disabled for that run (default: 4).
|
|
205
|
+
#DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH=4
|
|
203
206
|
|
|
204
207
|
# Allow the AI to spawn parallel sub-agents in target channels.
|
|
205
208
|
# Each spawned agent is a fire-and-forget invocation that posts output to the specified channel.
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { parseAllowBotIds, parseAllowChannelIds, parseAllowUserIds } from './dis
|
|
|
2
2
|
export const KNOWN_TOOLS = new Set(['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch']);
|
|
3
3
|
export const DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS = 1800;
|
|
4
4
|
export const DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT = 5;
|
|
5
|
+
export const DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DEPTH = 4;
|
|
5
6
|
function parseBoolean(env, name, defaultValue) {
|
|
6
7
|
const raw = env[name];
|
|
7
8
|
if (raw == null || raw.trim() === '')
|
|
@@ -162,6 +163,7 @@ export function parseConfig(env) {
|
|
|
162
163
|
const spawnMaxConcurrent = parsePositiveInt(env, 'DISCOCLAW_DISCORD_ACTIONS_SPAWN_MAX_CONCURRENT', 4);
|
|
163
164
|
const deferMaxDelaySeconds = parsePositiveNumber(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS', DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS);
|
|
164
165
|
const deferMaxConcurrent = parsePositiveInt(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT', DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT);
|
|
166
|
+
const deferMaxDepth = parsePositiveInt(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH', DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DEPTH);
|
|
165
167
|
if (!discordActionsEnabled) {
|
|
166
168
|
const enabledCategories = [
|
|
167
169
|
{ name: 'DISCOCLAW_DISCORD_ACTIONS_CHANNELS', enabled: discordActionsChannels },
|
|
@@ -366,6 +368,7 @@ export function parseConfig(env) {
|
|
|
366
368
|
discordActionsVoice,
|
|
367
369
|
discordActionsSpawn,
|
|
368
370
|
deferMaxDelaySeconds,
|
|
371
|
+
deferMaxDepth,
|
|
369
372
|
deferMaxConcurrent,
|
|
370
373
|
spawnMaxConcurrent,
|
|
371
374
|
messageHistoryBudget: parseNonNegativeInt(env, 'DISCOCLAW_MESSAGE_HISTORY_BUDGET', 3000),
|
package/dist/config.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { parseConfig, DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT, DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS, } from './config.js';
|
|
2
|
+
import { parseConfig, DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT, DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS, DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DEPTH, } from './config.js';
|
|
3
3
|
function env(overrides = {}) {
|
|
4
4
|
return {
|
|
5
5
|
DISCORD_TOKEN: 'token',
|
|
@@ -110,16 +110,28 @@ describe('parseConfig', () => {
|
|
|
110
110
|
expect(config.discordActionsDefer).toBe(true);
|
|
111
111
|
expect(config.deferMaxDelaySeconds).toBe(DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS);
|
|
112
112
|
expect(config.deferMaxConcurrent).toBe(DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT);
|
|
113
|
+
expect(config.deferMaxDepth).toBe(DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DEPTH);
|
|
114
|
+
expect(config.deferMaxDepth).toBe(4);
|
|
113
115
|
});
|
|
114
116
|
it('parses defer config overrides', () => {
|
|
115
117
|
const { config } = parseConfig(env({
|
|
116
118
|
DISCOCLAW_DISCORD_ACTIONS_DEFER: '1',
|
|
117
119
|
DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS: '900',
|
|
118
120
|
DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT: '2',
|
|
121
|
+
DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH: '8',
|
|
119
122
|
}));
|
|
120
123
|
expect(config.discordActionsDefer).toBe(true);
|
|
121
124
|
expect(config.deferMaxDelaySeconds).toBe(900);
|
|
122
125
|
expect(config.deferMaxConcurrent).toBe(2);
|
|
126
|
+
expect(config.deferMaxDepth).toBe(8);
|
|
127
|
+
});
|
|
128
|
+
it('throws on non-positive or non-integer deferMaxDepth', () => {
|
|
129
|
+
expect(() => parseConfig(env({ DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH: '0' })))
|
|
130
|
+
.toThrow(/DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH must be a positive number/);
|
|
131
|
+
expect(() => parseConfig(env({ DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH: '-1' })))
|
|
132
|
+
.toThrow(/DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH must be a positive number/);
|
|
133
|
+
expect(() => parseConfig(env({ DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH: '2.5' })))
|
|
134
|
+
.toThrow(/DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH must be an integer/);
|
|
123
135
|
});
|
|
124
136
|
it('reports ignored action category flags as info-level advisories', () => {
|
|
125
137
|
const { warnings, infos } = parseConfig(env({
|
package/dist/discord/actions.js
CHANGED
|
@@ -681,7 +681,7 @@ If an action fails with a "Missing Permissions" or "Missing Access" error, tell
|
|
|
681
681
|
4. The bot may need to be re-invited with the "moderator" permission profile if the role wasn't granted at invite time.`);
|
|
682
682
|
if (flags.defer) {
|
|
683
683
|
sections.push(`### Deferred self-invocation
|
|
684
|
-
Use a <discord-action>{"type":"defer","channel":"general","delaySeconds":600,"prompt":"Check on the forge run"}</discord-action> block to schedule a follow-up run inside the requested channel without another user prompt. You must specify the channel by name or ID; delaySeconds is how long to wait (capped by DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS) and prompt becomes the user message when the deferred invocation runs. The scheduler enforces DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT pending jobs, respects the same channel permissions as this response, automatically posts the follow-up output, and
|
|
684
|
+
Use a <discord-action>{"type":"defer","channel":"general","delaySeconds":600,"prompt":"Check on the forge run"}</discord-action> block to schedule a follow-up run inside the requested channel without another user prompt. You must specify the channel by name or ID; delaySeconds is how long to wait (capped by DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS) and prompt becomes the user message when the deferred invocation runs. The scheduler enforces DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT pending jobs, respects the same channel permissions as this response, automatically posts the follow-up output, and allows nested defers up to the configured depth limit (DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH, default 4); once the limit is reached, \`defer\` is disabled for that run. If a guard rail rejects the request (too long, too many active defers, missing permissions, or the channel becomes invalid) the action fails with an explanatory message.`);
|
|
685
685
|
}
|
|
686
686
|
return sections.join('\n\n');
|
|
687
687
|
}
|
|
@@ -626,7 +626,7 @@ describe('discordActionsPromptSection', () => {
|
|
|
626
626
|
expect(prompt).toContain('without another user prompt');
|
|
627
627
|
expect(prompt).toContain('DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS');
|
|
628
628
|
expect(prompt).toContain('DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT');
|
|
629
|
-
expect(prompt).toContain('
|
|
629
|
+
expect(prompt).toContain('DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DEPTH');
|
|
630
630
|
});
|
|
631
631
|
});
|
|
632
632
|
// ---------------------------------------------------------------------------
|
|
@@ -20,7 +20,7 @@ function getThreadParentId(candidate) {
|
|
|
20
20
|
return null;
|
|
21
21
|
return String(channel.parentId);
|
|
22
22
|
}
|
|
23
|
-
function buildDeferredActionFlags(state) {
|
|
23
|
+
function buildDeferredActionFlags(state, depth, maxDepth) {
|
|
24
24
|
return {
|
|
25
25
|
channels: state.discordActionsChannels,
|
|
26
26
|
messaging: state.discordActionsMessaging,
|
|
@@ -35,7 +35,7 @@ function buildDeferredActionFlags(state) {
|
|
|
35
35
|
// Deferred runs do not carry a user identity, so memory actions stay disabled.
|
|
36
36
|
memory: false,
|
|
37
37
|
config: Boolean(state.discordActionsConfig),
|
|
38
|
-
defer:
|
|
38
|
+
defer: depth < maxDepth,
|
|
39
39
|
imagegen: Boolean(state.discordActionsImagegen),
|
|
40
40
|
voice: Boolean(state.discordActionsVoice),
|
|
41
41
|
};
|
|
@@ -82,7 +82,8 @@ export function configureDeferredScheduler(opts) {
|
|
|
82
82
|
opts.log?.warn({ flow: 'defer', channelId: channel.id, err }, 'defer:context inline failed');
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
const
|
|
85
|
+
const deferDepth = (context.deferDepth ?? 0) + 1;
|
|
86
|
+
const deferredActionFlags = buildDeferredActionFlags(opts.state, deferDepth, opts.deferMaxDepth);
|
|
86
87
|
const openTasksSection = buildOpenTasksSection(opts.state.taskCtx?.store);
|
|
87
88
|
let prompt = buildPromptPreamble(inlinedContext) + '\n\n' +
|
|
88
89
|
(openTasksSection
|
|
@@ -179,6 +180,8 @@ export function configureDeferredScheduler(opts) {
|
|
|
179
180
|
channelId: channel.id,
|
|
180
181
|
messageId: `defer-${Date.now()}`,
|
|
181
182
|
threadParentId,
|
|
183
|
+
deferScheduler: context.deferScheduler,
|
|
184
|
+
deferDepth,
|
|
182
185
|
transport: new DiscordTransportClient(guild, context.client),
|
|
183
186
|
confirmation: {
|
|
184
187
|
mode: 'automated',
|
|
@@ -76,6 +76,7 @@ function makeOpts(overrides = {}) {
|
|
|
76
76
|
return {
|
|
77
77
|
maxDelaySeconds: 3600,
|
|
78
78
|
maxConcurrent: 5,
|
|
79
|
+
deferMaxDepth: 4,
|
|
79
80
|
state: makeState(),
|
|
80
81
|
runtime: makeRuntime([{ type: 'text_final', text: 'Hello' }, { type: 'done' }]),
|
|
81
82
|
runtimeTools: [],
|
|
@@ -221,4 +222,81 @@ describe('deferred-runner observability', () => {
|
|
|
221
222
|
scheduler.schedule({ action: makeAction(), context: makeContext() });
|
|
222
223
|
await expect(vi.advanceTimersByTimeAsync(2000)).resolves.not.toThrow();
|
|
223
224
|
});
|
|
225
|
+
it('flags have defer: true when depth is below maxDepth', async () => {
|
|
226
|
+
const { parseDiscordActions } = await import('./actions.js');
|
|
227
|
+
const mockParse = parseDiscordActions;
|
|
228
|
+
mockParse.mockClear();
|
|
229
|
+
mockParse.mockReturnValue({
|
|
230
|
+
actions: [],
|
|
231
|
+
cleanText: 'ok',
|
|
232
|
+
strippedUnrecognizedTypes: [],
|
|
233
|
+
parseFailures: 0,
|
|
234
|
+
});
|
|
235
|
+
const opts = makeOpts({ deferMaxDepth: 4 });
|
|
236
|
+
const scheduler = configureDeferredScheduler(opts);
|
|
237
|
+
// context with no deferDepth (defaults to 0) → depth becomes 1, which is < 4
|
|
238
|
+
scheduler.schedule({ action: makeAction(), context: makeContext() });
|
|
239
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
240
|
+
const flags = mockParse.mock.calls[0][1];
|
|
241
|
+
expect(flags.defer).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
it('flags have defer: false when depth equals maxDepth', async () => {
|
|
244
|
+
const { parseDiscordActions } = await import('./actions.js');
|
|
245
|
+
const mockParse = parseDiscordActions;
|
|
246
|
+
mockParse.mockClear();
|
|
247
|
+
mockParse.mockReturnValue({
|
|
248
|
+
actions: [],
|
|
249
|
+
cleanText: 'ok',
|
|
250
|
+
strippedUnrecognizedTypes: [],
|
|
251
|
+
parseFailures: 0,
|
|
252
|
+
});
|
|
253
|
+
const opts = makeOpts({ deferMaxDepth: 4 });
|
|
254
|
+
const scheduler = configureDeferredScheduler(opts);
|
|
255
|
+
// context with deferDepth 3 → depth becomes 4, which equals maxDepth
|
|
256
|
+
const ctx = { ...makeContext(), deferDepth: 3 };
|
|
257
|
+
scheduler.schedule({ action: makeAction(), context: ctx });
|
|
258
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
259
|
+
const flags = mockParse.mock.calls[0][1];
|
|
260
|
+
expect(flags.defer).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
it('actCtx carries deferDepth incremented by 1 from incoming context', async () => {
|
|
263
|
+
const { parseDiscordActions, executeDiscordActions } = await import('./actions.js');
|
|
264
|
+
const mockParse = parseDiscordActions;
|
|
265
|
+
const mockExecute = executeDiscordActions;
|
|
266
|
+
mockParse.mockClear();
|
|
267
|
+
mockExecute.mockClear();
|
|
268
|
+
mockParse.mockReturnValue({
|
|
269
|
+
actions: [{ type: 'sendMessage', content: 'hi' }],
|
|
270
|
+
cleanText: '',
|
|
271
|
+
strippedUnrecognizedTypes: [],
|
|
272
|
+
parseFailures: 0,
|
|
273
|
+
});
|
|
274
|
+
mockExecute.mockResolvedValue([{ ok: true, summary: 'sent' }]);
|
|
275
|
+
const opts = makeOpts({ deferMaxDepth: 4 });
|
|
276
|
+
const scheduler = configureDeferredScheduler(opts);
|
|
277
|
+
const ctx = { ...makeContext(), deferDepth: 2 };
|
|
278
|
+
scheduler.schedule({ action: makeAction(), context: ctx });
|
|
279
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
280
|
+
const actCtx = mockExecute.mock.calls[0][1];
|
|
281
|
+
expect(actCtx.deferDepth).toBe(3);
|
|
282
|
+
});
|
|
283
|
+
it('deferMaxDepth 1 allows first level but blocks second', async () => {
|
|
284
|
+
const { parseDiscordActions } = await import('./actions.js');
|
|
285
|
+
const mockParse = parseDiscordActions;
|
|
286
|
+
// First level: deferDepth undefined → depth = 1, maxDepth = 1 → defer: false
|
|
287
|
+
mockParse.mockClear();
|
|
288
|
+
mockParse.mockReturnValue({
|
|
289
|
+
actions: [],
|
|
290
|
+
cleanText: 'ok',
|
|
291
|
+
strippedUnrecognizedTypes: [],
|
|
292
|
+
parseFailures: 0,
|
|
293
|
+
});
|
|
294
|
+
const opts = makeOpts({ deferMaxDepth: 1 });
|
|
295
|
+
const scheduler = configureDeferredScheduler(opts);
|
|
296
|
+
scheduler.schedule({ action: makeAction(), context: makeContext() });
|
|
297
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
298
|
+
// depth = 0 + 1 = 1, maxDepth = 1 → 1 < 1 is false → defer: false
|
|
299
|
+
const flags = mockParse.mock.calls[0][1];
|
|
300
|
+
expect(flags.defer).toBe(false);
|
|
301
|
+
});
|
|
224
302
|
});
|
|
@@ -97,7 +97,8 @@ export async function handleUpdateCommand(cmd, opts = {}) {
|
|
|
97
97
|
}
|
|
98
98
|
if (npmMode) {
|
|
99
99
|
progress('Installing latest version from npm...');
|
|
100
|
-
const
|
|
100
|
+
const npmEnv = { ...process.env, CFLAGS: '-Wno-incompatible-pointer-types' };
|
|
101
|
+
const install = await run('npm', ['install', '-g', 'discoclaw@latest', '--loglevel=error'], { timeout: 120_000, env: npmEnv });
|
|
101
102
|
if (install.exitCode !== 0) {
|
|
102
103
|
const detail = (install.stderr || install.stdout).trim().slice(0, 500);
|
|
103
104
|
return { reply: `\`npm install -g discoclaw@latest\` failed:\n\`\`\`\n${detail}\n\`\`\`` };
|
package/dist/index.js
CHANGED
|
@@ -866,6 +866,7 @@ if (discordActionsEnabled && cfg.discordActionsDefer) {
|
|
|
866
866
|
deferOpts = {
|
|
867
867
|
maxDelaySeconds: cfg.deferMaxDelaySeconds,
|
|
868
868
|
maxConcurrent: cfg.deferMaxConcurrent,
|
|
869
|
+
deferMaxDepth: cfg.deferMaxDepth,
|
|
869
870
|
state: botParams,
|
|
870
871
|
runtime,
|
|
871
872
|
runtimeTools,
|
package/dist/npm-managed.js
CHANGED
|
@@ -50,6 +50,7 @@ export async function npmGlobalUpgrade() {
|
|
|
50
50
|
try {
|
|
51
51
|
const result = await execa('npm', ['install', '-g', 'discoclaw', '--loglevel=error'], {
|
|
52
52
|
timeout: 120_000,
|
|
53
|
+
env: { ...process.env, CFLAGS: '-Wno-incompatible-pointer-types' },
|
|
53
54
|
});
|
|
54
55
|
return {
|
|
55
56
|
exitCode: result.exitCode ?? 0,
|
package/dist/npm-managed.test.js
CHANGED
|
@@ -83,6 +83,7 @@ describe('npmGlobalUpgrade', () => {
|
|
|
83
83
|
expect(result.stderr).toBe('');
|
|
84
84
|
expect(mockExeca).toHaveBeenCalledWith('npm', ['install', '-g', 'discoclaw', '--loglevel=error'], {
|
|
85
85
|
timeout: 120_000,
|
|
86
|
+
env: expect.objectContaining({ CFLAGS: '-Wno-incompatible-pointer-types' }),
|
|
86
87
|
});
|
|
87
88
|
});
|
|
88
89
|
it('returns a non-zero exitCode and stderr when npm install fails', async () => {
|