kimaki 0.28.0 → 0.29.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.
- package/dist/bash-tool-schema-plugin.js +18 -0
- package/dist/bash-tool-schema-plugin.test.js +20 -1
- package/dist/channel-management.js +113 -51
- package/dist/cli-commands/misc.js +4 -1
- package/dist/cli-commands/project.js +7 -4
- package/dist/cli-commands/session.js +84 -33
- package/dist/cli-commands/thread.js +63 -0
- package/dist/cli.js +2 -0
- package/dist/commands/queue.js +79 -31
- package/dist/commands/queue.test.js +15 -0
- package/dist/commands/restart-opencode-server.js +7 -1
- package/dist/commands/session-id.js +5 -5
- package/dist/database.js +76 -0
- package/dist/db.js +34 -0
- package/dist/db.test.js +96 -1
- package/dist/default-channel-provisioning.test.js +2 -1
- package/dist/discord-bot.js +9 -3
- package/dist/discord-command-registration.js +1 -1
- package/dist/guild-categories.test.js +225 -0
- package/dist/interaction-handler.js +5 -1
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/kimaki-opencode-plugin.test.js +28 -0
- package/dist/list-channel-threads.js +163 -0
- package/dist/list-channel-threads.test.js +80 -0
- package/dist/message-formatting.js +9 -16
- package/dist/message-formatting.test.js +91 -2
- package/dist/schema.js +21 -0
- package/dist/session-handler/event-stream-state.js +53 -5
- package/dist/session-handler/event-stream-state.test.js +223 -0
- package/dist/session-handler/thread-runtime-state.js +4 -0
- package/dist/session-handler/thread-session-runtime.js +314 -73
- package/dist/system-message.js +55 -13
- package/dist/system-message.test.js +50 -13
- package/dist/task-id-plugin.js +16 -0
- package/package.json +5 -5
- package/skills/strada/SKILL.md +61 -232
- package/src/bash-tool-schema-plugin.test.ts +29 -0
- package/src/bash-tool-schema-plugin.ts +31 -0
- package/src/channel-management.ts +144 -65
- package/src/cli-commands/misc.ts +4 -1
- package/src/cli-commands/project.ts +9 -4
- package/src/cli-commands/session.ts +123 -34
- package/src/cli-commands/thread.ts +86 -0
- package/src/cli.ts +2 -0
- package/src/commands/queue.test.ts +19 -0
- package/src/commands/queue.ts +106 -40
- package/src/commands/restart-opencode-server.ts +12 -1
- package/src/commands/session-id.ts +5 -5
- package/src/database.ts +114 -0
- package/src/db.test.ts +112 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +2 -1
- package/src/discord-bot.ts +11 -2
- package/src/discord-command-registration.ts +3 -1
- package/src/guild-categories.test.ts +260 -0
- package/src/interaction-handler.ts +7 -0
- package/src/kimaki-opencode-plugin.test.ts +34 -0
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/list-channel-threads.test.ts +90 -0
- package/src/list-channel-threads.ts +252 -0
- package/src/message-formatting.test.ts +105 -1
- package/src/message-formatting.ts +7 -12
- package/src/schema.sql +16 -0
- package/src/schema.ts +23 -0
- package/src/session-handler/event-stream-state.test.ts +226 -0
- package/src/session-handler/event-stream-state.ts +57 -5
- package/src/session-handler/thread-runtime-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +380 -81
- package/src/system-message.test.ts +50 -13
- package/src/system-message.ts +58 -12
- package/src/task-id-plugin.ts +17 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { afterEach, beforeEach, expect, test } from 'vitest';
|
|
4
|
+
import { ChannelType, Client, Events, GatewayIntentBits } from 'discord.js';
|
|
5
|
+
import { DigitalDiscord } from 'discord-digital-twin/src';
|
|
6
|
+
import { setDataDir } from './config.js';
|
|
7
|
+
import { closeDb } from './db.js';
|
|
8
|
+
import { createDefaultKimakiChannel, createProjectChannels, ensureKimakiCategory, } from './channel-management.js';
|
|
9
|
+
import { getGuildCategories, initDatabase, setChannelDirectory, } from './database.js';
|
|
10
|
+
const guildId = '100000000000000111';
|
|
11
|
+
const otherGuildId = '100000000000000222';
|
|
12
|
+
let directory;
|
|
13
|
+
let discord;
|
|
14
|
+
let client;
|
|
15
|
+
async function startDiscord({ dataDir }) {
|
|
16
|
+
discord = new DigitalDiscord({
|
|
17
|
+
dbUrl: `file:${path.join(dataDir, 'discord.db')}`,
|
|
18
|
+
users: [{ id: '100000000000000555', username: 'server-owner' }],
|
|
19
|
+
guilds: [
|
|
20
|
+
{ id: guildId, name: 'Studio', ownerId: '100000000000000555' },
|
|
21
|
+
{ id: otherGuildId, name: 'Office', ownerId: '100000000000000555' },
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
await discord.start();
|
|
25
|
+
client = new Client({
|
|
26
|
+
intents: [GatewayIntentBits.Guilds],
|
|
27
|
+
rest: { api: discord.restUrl, version: '10' },
|
|
28
|
+
});
|
|
29
|
+
const ready = new Promise((resolve) => client.once(Events.ClientReady, () => resolve()));
|
|
30
|
+
await client.login(discord.botToken);
|
|
31
|
+
await ready;
|
|
32
|
+
}
|
|
33
|
+
async function openDataDir() {
|
|
34
|
+
const root = path.resolve('tmp/guild-categories');
|
|
35
|
+
fs.mkdirSync(root, { recursive: true });
|
|
36
|
+
directory = fs.mkdtempSync(path.join(root, 'run-'));
|
|
37
|
+
setDataDir(directory);
|
|
38
|
+
await closeDb();
|
|
39
|
+
await initDatabase();
|
|
40
|
+
}
|
|
41
|
+
beforeEach(async () => {
|
|
42
|
+
await openDataDir();
|
|
43
|
+
await startDiscord({ dataDir: directory });
|
|
44
|
+
});
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
await client?.destroy();
|
|
47
|
+
await discord?.stop();
|
|
48
|
+
await closeDb();
|
|
49
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
function guild() {
|
|
52
|
+
return client.guilds.cache.get(guildId);
|
|
53
|
+
}
|
|
54
|
+
test('first project creates a Kimaki group and stores its id', async () => {
|
|
55
|
+
const created = await createProjectChannels({
|
|
56
|
+
guild: guild(),
|
|
57
|
+
projectDirectory: path.join(directory, 'web'),
|
|
58
|
+
});
|
|
59
|
+
const channel = await guild().channels.fetch(created.textChannelId);
|
|
60
|
+
expect(channel?.parentId).toBeTruthy();
|
|
61
|
+
const category = await guild().channels.fetch(channel.parentId);
|
|
62
|
+
expect(category?.name).toBe('Kimaki');
|
|
63
|
+
expect(await getGuildCategories(guildId)).toEqual({
|
|
64
|
+
guild_id: guildId,
|
|
65
|
+
category_id: category.id,
|
|
66
|
+
audio_category_id: null,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
test('renamed group still receives new project channels', async () => {
|
|
70
|
+
const first = await createProjectChannels({
|
|
71
|
+
guild: guild(),
|
|
72
|
+
projectDirectory: path.join(directory, 'web'),
|
|
73
|
+
});
|
|
74
|
+
const firstChannel = await guild().channels.fetch(first.textChannelId);
|
|
75
|
+
const category = await guild().channels.fetch(firstChannel.parentId);
|
|
76
|
+
await category.setName('laptop');
|
|
77
|
+
const second = await createProjectChannels({
|
|
78
|
+
guild: guild(),
|
|
79
|
+
projectDirectory: path.join(directory, 'api'),
|
|
80
|
+
});
|
|
81
|
+
const secondChannel = await guild().channels.fetch(second.textChannelId);
|
|
82
|
+
expect(secondChannel?.parentId).toBe(firstChannel?.parentId);
|
|
83
|
+
expect((await guild().channels.fetch(secondChannel.parentId))?.name).toBe('laptop');
|
|
84
|
+
});
|
|
85
|
+
test('a second machine creates its own Kimaki group', async () => {
|
|
86
|
+
const first = await createProjectChannels({
|
|
87
|
+
guild: guild(),
|
|
88
|
+
projectDirectory: path.join(directory, 'web'),
|
|
89
|
+
});
|
|
90
|
+
const firstParent = (await guild().channels.fetch(first.textChannelId))
|
|
91
|
+
?.parentId;
|
|
92
|
+
await closeDb();
|
|
93
|
+
await openDataDir();
|
|
94
|
+
const second = await createProjectChannels({
|
|
95
|
+
guild: guild(),
|
|
96
|
+
projectDirectory: path.join(directory, 'api'),
|
|
97
|
+
});
|
|
98
|
+
const secondParent = (await guild().channels.fetch(second.textChannelId))
|
|
99
|
+
?.parentId;
|
|
100
|
+
expect(secondParent).toBeTruthy();
|
|
101
|
+
expect(secondParent).not.toBe(firstParent);
|
|
102
|
+
const categories = [...guild().channels.cache.values()].filter((channel) => channel.type === ChannelType.GuildCategory);
|
|
103
|
+
expect(categories.map((channel) => channel.name).sort()).toEqual([
|
|
104
|
+
'Kimaki',
|
|
105
|
+
'Kimaki',
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
test('existing project channels adopt their parent without creating a new group', async () => {
|
|
109
|
+
const category = await guild().channels.create({
|
|
110
|
+
name: 'studio',
|
|
111
|
+
type: ChannelType.GuildCategory,
|
|
112
|
+
});
|
|
113
|
+
const text = await guild().channels.create({
|
|
114
|
+
name: 'web',
|
|
115
|
+
type: ChannelType.GuildText,
|
|
116
|
+
parent: category,
|
|
117
|
+
});
|
|
118
|
+
await setChannelDirectory({
|
|
119
|
+
channelId: text.id,
|
|
120
|
+
directory: path.join(directory, 'web'),
|
|
121
|
+
channelType: 'text',
|
|
122
|
+
guildId,
|
|
123
|
+
});
|
|
124
|
+
const resolved = await ensureKimakiCategory(guild());
|
|
125
|
+
expect(resolved.id).toBe(category.id);
|
|
126
|
+
expect(await getGuildCategories(guildId)).toMatchObject({
|
|
127
|
+
guild_id: guildId,
|
|
128
|
+
category_id: category.id,
|
|
129
|
+
});
|
|
130
|
+
const categoryCount = [...guild().channels.cache.values()].filter((channel) => channel.type === ChannelType.GuildCategory).length;
|
|
131
|
+
expect(categoryCount).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
test('a second machine gets its own default kimaki channel', async () => {
|
|
134
|
+
const first = await createDefaultKimakiChannel({
|
|
135
|
+
guild: guild(),
|
|
136
|
+
appId: discord.botUserId,
|
|
137
|
+
isGatewayMode: true,
|
|
138
|
+
});
|
|
139
|
+
expect(first).toBeTruthy();
|
|
140
|
+
await closeDb();
|
|
141
|
+
await openDataDir();
|
|
142
|
+
const second = await createDefaultKimakiChannel({
|
|
143
|
+
guild: guild(),
|
|
144
|
+
appId: discord.botUserId,
|
|
145
|
+
isGatewayMode: true,
|
|
146
|
+
});
|
|
147
|
+
expect(second).toBeTruthy();
|
|
148
|
+
expect(second.textChannelId).not.toBe(first.textChannelId);
|
|
149
|
+
expect(second.textChannel.parentId).not.toBe(first.textChannel.parentId);
|
|
150
|
+
});
|
|
151
|
+
test('legacy rows from another guild do not block adoption', async () => {
|
|
152
|
+
const otherGuild = client.guilds.cache.get(otherGuildId);
|
|
153
|
+
const foreignCategory = await otherGuild.channels.create({
|
|
154
|
+
name: 'foreign',
|
|
155
|
+
type: ChannelType.GuildCategory,
|
|
156
|
+
});
|
|
157
|
+
const foreignChannel = await otherGuild.channels.create({
|
|
158
|
+
name: 'web',
|
|
159
|
+
type: ChannelType.GuildText,
|
|
160
|
+
parent: foreignCategory,
|
|
161
|
+
});
|
|
162
|
+
const localCategory = await guild().channels.create({
|
|
163
|
+
name: 'studio',
|
|
164
|
+
type: ChannelType.GuildCategory,
|
|
165
|
+
});
|
|
166
|
+
const localChannel = await guild().channels.create({
|
|
167
|
+
name: 'api',
|
|
168
|
+
type: ChannelType.GuildText,
|
|
169
|
+
parent: localCategory,
|
|
170
|
+
});
|
|
171
|
+
await setChannelDirectory({
|
|
172
|
+
channelId: foreignChannel.id,
|
|
173
|
+
directory: path.join(directory, 'foreign'),
|
|
174
|
+
channelType: 'text',
|
|
175
|
+
});
|
|
176
|
+
await setChannelDirectory({
|
|
177
|
+
channelId: localChannel.id,
|
|
178
|
+
directory: path.join(directory, 'api'),
|
|
179
|
+
channelType: 'text',
|
|
180
|
+
});
|
|
181
|
+
const resolved = await ensureKimakiCategory(guild());
|
|
182
|
+
expect(resolved.id).toBe(localCategory.id);
|
|
183
|
+
});
|
|
184
|
+
test('a kimaki-prefixed project channel does not block the default channel', async () => {
|
|
185
|
+
await createProjectChannels({
|
|
186
|
+
guild: guild(),
|
|
187
|
+
projectDirectory: path.join(directory, 'kimaki-tools'),
|
|
188
|
+
});
|
|
189
|
+
const created = await createDefaultKimakiChannel({
|
|
190
|
+
guild: guild(),
|
|
191
|
+
appId: discord.botUserId,
|
|
192
|
+
isGatewayMode: true,
|
|
193
|
+
});
|
|
194
|
+
expect(created).toBeTruthy();
|
|
195
|
+
expect(created.channelName).toBe('kimaki');
|
|
196
|
+
});
|
|
197
|
+
test('concurrent first creates share one group', async () => {
|
|
198
|
+
const [first, second] = await Promise.all([
|
|
199
|
+
ensureKimakiCategory(guild()),
|
|
200
|
+
ensureKimakiCategory(guild()),
|
|
201
|
+
]);
|
|
202
|
+
expect(first.id).toBe(second.id);
|
|
203
|
+
const categories = [...guild().channels.cache.values()].filter((channel) => channel.type === ChannelType.GuildCategory);
|
|
204
|
+
expect(categories).toHaveLength(1);
|
|
205
|
+
});
|
|
206
|
+
test('deleted group is replaced and SQLite is updated', async () => {
|
|
207
|
+
const first = await createProjectChannels({
|
|
208
|
+
guild: guild(),
|
|
209
|
+
projectDirectory: path.join(directory, 'web'),
|
|
210
|
+
});
|
|
211
|
+
const firstParent = (await guild().channels.fetch(first.textChannelId))
|
|
212
|
+
?.parentId;
|
|
213
|
+
await (await guild().channels.fetch(firstParent)).delete();
|
|
214
|
+
const second = await createProjectChannels({
|
|
215
|
+
guild: guild(),
|
|
216
|
+
projectDirectory: path.join(directory, 'api'),
|
|
217
|
+
});
|
|
218
|
+
const secondParent = (await guild().channels.fetch(second.textChannelId))
|
|
219
|
+
?.parentId;
|
|
220
|
+
expect(secondParent).toBeTruthy();
|
|
221
|
+
expect(secondParent).not.toBe(firstParent);
|
|
222
|
+
expect(await getGuildCategories(guildId)).toMatchObject({
|
|
223
|
+
category_id: secondParent,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -28,7 +28,7 @@ import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
|
|
|
28
28
|
import { handleFileUploadButton, handleFileUploadModalSubmit, } from './commands/file-upload.js';
|
|
29
29
|
import { handleActionButton } from './commands/action-buttons.js';
|
|
30
30
|
import { handleHtmlActionButton } from './html-actions.js';
|
|
31
|
-
import { handleQueueCommand, handleClearQueueCommand, handleQueueCommandCommand, handleQueueCommandAutocomplete, } from './commands/queue.js';
|
|
31
|
+
import { handleQueueCommand, handleClearQueueCommand, handleQueueCommandCommand, handleQueueCommandAutocomplete, handleQueueRemoveButton, QUEUE_REMOVE_CUSTOM_ID_PREFIX, } from './commands/queue.js';
|
|
32
32
|
import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
|
|
33
33
|
import { handleUserCommand } from './commands/user-command.js';
|
|
34
34
|
import { handleVerbosityCommand, handleVerbositySelectMenu, } from './commands/verbosity.js';
|
|
@@ -397,6 +397,10 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
397
397
|
await handleActionButton(interaction);
|
|
398
398
|
return;
|
|
399
399
|
}
|
|
400
|
+
if (customId.startsWith(QUEUE_REMOVE_CUSTOM_ID_PREFIX)) {
|
|
401
|
+
await handleQueueRemoveButton(interaction);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
400
404
|
if (customId.startsWith('html_action:')) {
|
|
401
405
|
await handleHtmlActionButton(interaction);
|
|
402
406
|
return;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// - kitty-graphics-plugin: extract Kitty Graphics Protocol images from bash output
|
|
12
12
|
// - file-edit-log: record edit/write/apply_patch files per session
|
|
13
13
|
// - bash-tool-schema-plugin: add description and hasSideEffect to bash
|
|
14
|
+
// - task-id-plugin: drop invalid model-generated task resume IDs
|
|
14
15
|
export { ipcToolsPlugin } from './ipc-tools-plugin.js';
|
|
15
16
|
export { contextAwarenessPlugin } from './context-awareness-plugin.js';
|
|
16
17
|
export { memoryOverviewPlugin } from './memory-overview-plugin.js';
|
|
@@ -29,3 +30,4 @@ export { injectionGuardInternal as injectionGuard } from 'opencode-injection-gua
|
|
|
29
30
|
export { kimakiWorkspaceAdaptorPlugin } from './kimaki-workspace-adaptor.js';
|
|
30
31
|
export { fileEditTrackerPlugin } from './file-edit-log.js';
|
|
31
32
|
export { bashToolSchemaPlugin } from './bash-tool-schema-plugin.js';
|
|
33
|
+
export { taskIdPlugin } from './task-id-plugin.js';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { test, expect, describe } from 'vitest';
|
|
2
2
|
import { condenseMemoryMd } from './condense-memory.js';
|
|
3
|
+
import { sanitizeTaskArguments } from './task-id-plugin.js';
|
|
3
4
|
describe('condenseMemoryMd', () => {
|
|
4
5
|
test('multiple headings with body content', () => {
|
|
5
6
|
const content = [
|
|
@@ -96,3 +97,30 @@ describe('condenseMemoryMd', () => {
|
|
|
96
97
|
`);
|
|
97
98
|
});
|
|
98
99
|
});
|
|
100
|
+
describe('sanitizeTaskArguments', () => {
|
|
101
|
+
test('removes a model-invented UUID task_id', () => {
|
|
102
|
+
const args = {
|
|
103
|
+
description: 'inspect repository',
|
|
104
|
+
prompt: 'Find the implementation',
|
|
105
|
+
subagent_type: 'explore',
|
|
106
|
+
task_id: 'ff120f9f-a28d-4d29-b691-b3a5f770759b',
|
|
107
|
+
};
|
|
108
|
+
sanitizeTaskArguments(args);
|
|
109
|
+
expect(args).toMatchInlineSnapshot(`
|
|
110
|
+
{
|
|
111
|
+
"description": "inspect repository",
|
|
112
|
+
"prompt": "Find the implementation",
|
|
113
|
+
"subagent_type": "explore",
|
|
114
|
+
}
|
|
115
|
+
`);
|
|
116
|
+
});
|
|
117
|
+
test('keeps an OpenCode session task_id for resume', () => {
|
|
118
|
+
const args = { task_id: 'ses_child' };
|
|
119
|
+
sanitizeTaskArguments(args);
|
|
120
|
+
expect(args).toMatchInlineSnapshot(`
|
|
121
|
+
{
|
|
122
|
+
"task_id": "ses_child",
|
|
123
|
+
}
|
|
124
|
+
`);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ChannelType, Routes } from 'discord.js';
|
|
2
|
+
import * as errore from 'errore';
|
|
3
|
+
import { DiscordApiError } from './errors.js';
|
|
4
|
+
import { isThreadChannelType } from './discord-utils.js';
|
|
5
|
+
export const DISCORD_THREAD_LIST_PAGE_SIZE = 100;
|
|
6
|
+
const ARCHIVED_THREAD_FETCH_LIMIT = 200;
|
|
7
|
+
class DiscordThreadListError extends errore.createTaggedError({
|
|
8
|
+
name: 'DiscordThreadListError',
|
|
9
|
+
message: 'Failed to list Discord threads for channel $channelId',
|
|
10
|
+
}) {
|
|
11
|
+
}
|
|
12
|
+
export function parseDiscordThreadPayload({ payload, parentId, guildId, }) {
|
|
13
|
+
if (!payload.id || !isThreadChannelType(payload.type ?? -1)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (payload.parent_id && payload.parent_id !== parentId) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const archived = Boolean(payload.thread_metadata?.archived);
|
|
20
|
+
return {
|
|
21
|
+
id: payload.id,
|
|
22
|
+
name: payload.name || payload.id,
|
|
23
|
+
parentId,
|
|
24
|
+
guildId: payload.guild_id || guildId,
|
|
25
|
+
archived,
|
|
26
|
+
archiveState: archived ? 'archived' : 'active',
|
|
27
|
+
lastMessageId: payload.last_message_id ?? null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function mergeDiscordChannelThreads({ threads, }) {
|
|
31
|
+
const byId = new Map();
|
|
32
|
+
for (const thread of threads) {
|
|
33
|
+
byId.set(thread.id, thread);
|
|
34
|
+
}
|
|
35
|
+
return [...byId.values()].sort((left, right) => {
|
|
36
|
+
if (left.archived !== right.archived) {
|
|
37
|
+
return left.archived ? 1 : -1;
|
|
38
|
+
}
|
|
39
|
+
const leftMessage = left.lastMessageId ?? '';
|
|
40
|
+
const rightMessage = right.lastMessageId ?? '';
|
|
41
|
+
if (leftMessage !== rightMessage) {
|
|
42
|
+
return rightMessage.localeCompare(leftMessage);
|
|
43
|
+
}
|
|
44
|
+
return left.name.localeCompare(right.name);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function parseThreadListPayload({ payload, parentId, guildId, }) {
|
|
48
|
+
if (!payload || typeof payload !== 'object') {
|
|
49
|
+
return { threads: [], hasMore: false };
|
|
50
|
+
}
|
|
51
|
+
const list = payload;
|
|
52
|
+
const threads = (list.threads ?? []).flatMap((thread) => {
|
|
53
|
+
const parsed = parseDiscordThreadPayload({
|
|
54
|
+
payload: thread,
|
|
55
|
+
parentId,
|
|
56
|
+
guildId,
|
|
57
|
+
});
|
|
58
|
+
return parsed ? [parsed] : [];
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
threads,
|
|
62
|
+
hasMore: Boolean(list.has_more),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function fetchDiscordJson({ rest, route, query, }) {
|
|
66
|
+
const result = await rest
|
|
67
|
+
.get(route, query ? { query } : undefined)
|
|
68
|
+
.catch((cause) => {
|
|
69
|
+
return new DiscordApiError({
|
|
70
|
+
status: String(Reflect.get(cause, 'status') ?? 'unknown'),
|
|
71
|
+
body: cause instanceof Error ? cause.message : String(cause),
|
|
72
|
+
cause,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
async function listArchivedPublicThreads({ rest, channelId, guildId, }) {
|
|
78
|
+
const threads = [];
|
|
79
|
+
let before;
|
|
80
|
+
while (threads.length < ARCHIVED_THREAD_FETCH_LIMIT) {
|
|
81
|
+
const query = new URLSearchParams({
|
|
82
|
+
limit: String(DISCORD_THREAD_LIST_PAGE_SIZE),
|
|
83
|
+
});
|
|
84
|
+
if (before) {
|
|
85
|
+
query.set('before', before);
|
|
86
|
+
}
|
|
87
|
+
const payload = await fetchDiscordJson({
|
|
88
|
+
rest,
|
|
89
|
+
route: Routes.channelThreads(channelId, 'public'),
|
|
90
|
+
query,
|
|
91
|
+
});
|
|
92
|
+
if (payload instanceof DiscordApiError) {
|
|
93
|
+
return payload;
|
|
94
|
+
}
|
|
95
|
+
const page = parseThreadListPayload({
|
|
96
|
+
payload,
|
|
97
|
+
parentId: channelId,
|
|
98
|
+
guildId,
|
|
99
|
+
});
|
|
100
|
+
threads.push(...page.threads);
|
|
101
|
+
const lastThread = page.threads.at(-1);
|
|
102
|
+
if (!page.hasMore || !lastThread) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
before = lastThread.id;
|
|
106
|
+
}
|
|
107
|
+
return threads;
|
|
108
|
+
}
|
|
109
|
+
export async function listDiscordChannelThreads({ rest, channelId, }) {
|
|
110
|
+
const channelPayload = await fetchDiscordJson({
|
|
111
|
+
rest,
|
|
112
|
+
route: Routes.channel(channelId),
|
|
113
|
+
});
|
|
114
|
+
if (channelPayload instanceof DiscordApiError) {
|
|
115
|
+
return { ok: false, error: channelPayload };
|
|
116
|
+
}
|
|
117
|
+
const channel = (channelPayload ?? {});
|
|
118
|
+
if (channel.type !== ChannelType.GuildText) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
error: new DiscordThreadListError({
|
|
122
|
+
channelId,
|
|
123
|
+
cause: new Error('Channel is not a guild text channel'),
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const guildId = channel.guild_id;
|
|
128
|
+
if (!guildId) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: new DiscordThreadListError({
|
|
132
|
+
channelId,
|
|
133
|
+
cause: new Error('Channel has no guild ID'),
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const activePayload = await fetchDiscordJson({
|
|
138
|
+
rest,
|
|
139
|
+
route: Routes.guildActiveThreads(guildId),
|
|
140
|
+
});
|
|
141
|
+
if (activePayload instanceof DiscordApiError) {
|
|
142
|
+
return { ok: false, error: activePayload };
|
|
143
|
+
}
|
|
144
|
+
const activeThreads = parseThreadListPayload({
|
|
145
|
+
payload: activePayload,
|
|
146
|
+
parentId: channelId,
|
|
147
|
+
guildId,
|
|
148
|
+
}).threads;
|
|
149
|
+
const archivedThreads = await listArchivedPublicThreads({
|
|
150
|
+
rest,
|
|
151
|
+
channelId,
|
|
152
|
+
guildId,
|
|
153
|
+
});
|
|
154
|
+
if (archivedThreads instanceof DiscordApiError) {
|
|
155
|
+
return { ok: false, error: archivedThreads };
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
threads: mergeDiscordChannelThreads({
|
|
160
|
+
threads: [...activeThreads, ...archivedThreads],
|
|
161
|
+
}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ChannelType } from 'discord.js';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { mergeDiscordChannelThreads, parseDiscordThreadPayload, } from './list-channel-threads.js';
|
|
4
|
+
describe('list-channel-threads', () => {
|
|
5
|
+
test('parses public threads and skips other channel types', () => {
|
|
6
|
+
const parsed = parseDiscordThreadPayload({
|
|
7
|
+
payload: {
|
|
8
|
+
id: 'thread-1',
|
|
9
|
+
name: 'Fix auth timeout',
|
|
10
|
+
type: ChannelType.PublicThread,
|
|
11
|
+
parent_id: 'channel-1',
|
|
12
|
+
guild_id: 'guild-1',
|
|
13
|
+
last_message_id: 'msg-9',
|
|
14
|
+
thread_metadata: { archived: false },
|
|
15
|
+
},
|
|
16
|
+
parentId: 'channel-1',
|
|
17
|
+
guildId: 'guild-1',
|
|
18
|
+
});
|
|
19
|
+
expect(parsed).toMatchInlineSnapshot(`
|
|
20
|
+
{
|
|
21
|
+
"archiveState": "active",
|
|
22
|
+
"archived": false,
|
|
23
|
+
"guildId": "guild-1",
|
|
24
|
+
"id": "thread-1",
|
|
25
|
+
"lastMessageId": "msg-9",
|
|
26
|
+
"name": "Fix auth timeout",
|
|
27
|
+
"parentId": "channel-1",
|
|
28
|
+
}
|
|
29
|
+
`);
|
|
30
|
+
expect(parseDiscordThreadPayload({
|
|
31
|
+
payload: {
|
|
32
|
+
id: 'not-a-thread',
|
|
33
|
+
name: 'website',
|
|
34
|
+
type: ChannelType.GuildText,
|
|
35
|
+
parent_id: 'category-1',
|
|
36
|
+
},
|
|
37
|
+
parentId: 'channel-1',
|
|
38
|
+
guildId: 'guild-1',
|
|
39
|
+
})).toBe(null);
|
|
40
|
+
});
|
|
41
|
+
test('keeps active threads first and drops duplicate ids', () => {
|
|
42
|
+
const merged = mergeDiscordChannelThreads({
|
|
43
|
+
threads: [
|
|
44
|
+
{
|
|
45
|
+
id: 'thread-old',
|
|
46
|
+
name: 'Old work',
|
|
47
|
+
parentId: 'channel-1',
|
|
48
|
+
guildId: 'guild-1',
|
|
49
|
+
archived: true,
|
|
50
|
+
archiveState: 'archived',
|
|
51
|
+
lastMessageId: '2',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'thread-new',
|
|
55
|
+
name: 'New work',
|
|
56
|
+
parentId: 'channel-1',
|
|
57
|
+
guildId: 'guild-1',
|
|
58
|
+
archived: false,
|
|
59
|
+
archiveState: 'active',
|
|
60
|
+
lastMessageId: '1',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'thread-new',
|
|
64
|
+
name: 'New work duplicate',
|
|
65
|
+
parentId: 'channel-1',
|
|
66
|
+
guildId: 'guild-1',
|
|
67
|
+
archived: false,
|
|
68
|
+
archiveState: 'active',
|
|
69
|
+
lastMessageId: '9',
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
expect(merged.map((thread) => thread.id)).toMatchInlineSnapshot(`
|
|
74
|
+
[
|
|
75
|
+
"thread-new",
|
|
76
|
+
"thread-old",
|
|
77
|
+
]
|
|
78
|
+
`);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -195,31 +195,22 @@ export function shouldQuoteIntermediateTextPart({ part, isLastInTurn, nextToolNa
|
|
|
195
195
|
return true;
|
|
196
196
|
}
|
|
197
197
|
export function planAssistantTurnFlush({ parts, mode, throughPartId, }) {
|
|
198
|
-
const lastText = parts.filter(isNonEmptyTextPart).at(-1);
|
|
199
|
-
const lastTextIndex = lastText
|
|
200
|
-
? parts.findLastIndex((part) => part.id === lastText.id)
|
|
201
|
-
: -1;
|
|
202
198
|
const throughIndex = throughPartId
|
|
203
199
|
? parts.findIndex((part) => part.id === throughPartId)
|
|
204
200
|
: -1;
|
|
205
201
|
const sendUntil = (() => {
|
|
206
|
-
if (mode === '
|
|
207
|
-
return
|
|
208
|
-
if (mode === 'interactive') {
|
|
209
|
-
if (throughIndex >= 0)
|
|
210
|
-
return throughIndex + 1;
|
|
211
|
-
return parts.length;
|
|
212
|
-
}
|
|
213
|
-
if (!lastText)
|
|
214
|
-
return parts.length;
|
|
215
|
-
// Hold unfinished last text and anything after it so tool order stays intact.
|
|
216
|
-
if (!lastText.time?.end)
|
|
217
|
-
return lastTextIndex;
|
|
202
|
+
if (mode === 'interactive' && throughIndex >= 0)
|
|
203
|
+
return throughIndex + 1;
|
|
218
204
|
return parts.length;
|
|
219
205
|
})();
|
|
220
206
|
const sendParts = [];
|
|
221
207
|
const holdParts = [];
|
|
222
208
|
for (const [index, part] of parts.entries()) {
|
|
209
|
+
// Live tools must not wait for the current text part to end.
|
|
210
|
+
if (mode === 'progress' && part.type === 'text' && !part.time?.end) {
|
|
211
|
+
holdParts.push(part);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
223
214
|
if (index < sendUntil) {
|
|
224
215
|
sendParts.push({
|
|
225
216
|
part,
|
|
@@ -632,6 +623,8 @@ export function formatTaskToolTitle(part) {
|
|
|
632
623
|
export function formatPart(part, prefix) {
|
|
633
624
|
const pfx = prefix ? `${prefix} ⋅ ` : '';
|
|
634
625
|
if (part.type === 'text') {
|
|
626
|
+
if (part.synthetic === true)
|
|
627
|
+
return '';
|
|
635
628
|
const text = part.text?.trim();
|
|
636
629
|
if (!text)
|
|
637
630
|
return '';
|