discoclaw 1.3.0 → 2.0.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 (56) hide show
  1. package/.env.example +4 -6
  2. package/.env.example.full +13 -32
  3. package/README.md +1 -1
  4. package/dist/cli/dashboard.test.js +0 -4
  5. package/dist/cli/init-wizard.js +4 -8
  6. package/dist/cli/init-wizard.test.js +4 -10
  7. package/dist/config.js +2 -42
  8. package/dist/config.test.js +8 -72
  9. package/dist/dashboard/server.js +1 -5
  10. package/dist/dashboard/server.test.js +3 -6
  11. package/dist/discord/actions.js +112 -6
  12. package/dist/discord/actions.test.js +117 -1
  13. package/dist/discord/help-command.js +1 -1
  14. package/dist/discord/message-coordinator.js +3 -8
  15. package/dist/discord/models-command.js +1 -1
  16. package/dist/discord/reaction-handler.js +2 -2
  17. package/dist/discord/reaction-handler.test.js +55 -0
  18. package/dist/discord/verify-push.js +31 -36
  19. package/dist/discord/verify-push.test.js +34 -6
  20. package/dist/discord/voice-command.js +1 -31
  21. package/dist/discord/voice-command.test.js +21 -259
  22. package/dist/discord/voice-status-command.js +3 -22
  23. package/dist/discord/voice-status-command.test.js +16 -124
  24. package/dist/discord-followup.test.js +133 -0
  25. package/dist/health/config-doctor.js +5 -27
  26. package/dist/health/config-doctor.test.js +1 -4
  27. package/dist/index.js +1 -28
  28. package/dist/runtime-overrides.js +2 -3
  29. package/dist/runtime-overrides.test.js +27 -193
  30. package/dist/tasks/store.js +10 -6
  31. package/dist/tasks/store.test.js +44 -0
  32. package/dist/tasks/task-action-executor.test.js +162 -50
  33. package/dist/tasks/task-action-mutations.js +22 -2
  34. package/dist/tasks/task-action-read-ops.js +7 -1
  35. package/dist/tasks/task-action-runner-types.js +19 -1
  36. package/dist/voice/audio-pipeline.js +145 -298
  37. package/docs/configuration.md +4 -9
  38. package/docs/official-docs.md +6 -9
  39. package/docs/runtime-switching.md +1 -1
  40. package/package.json +1 -1
  41. package/dist/voice/audio-pipeline.test.js +0 -1100
  42. package/dist/voice/stt-deepgram.js +0 -154
  43. package/dist/voice/stt-deepgram.test.js +0 -275
  44. package/dist/voice/stt-factory.js +0 -42
  45. package/dist/voice/stt-factory.test.js +0 -45
  46. package/dist/voice/stt-openai.js +0 -156
  47. package/dist/voice/stt-openai.test.js +0 -281
  48. package/dist/voice/tts-cartesia.js +0 -169
  49. package/dist/voice/tts-cartesia.test.js +0 -228
  50. package/dist/voice/tts-deepgram.js +0 -84
  51. package/dist/voice/tts-deepgram.test.js +0 -220
  52. package/dist/voice/tts-factory.js +0 -52
  53. package/dist/voice/tts-factory.test.js +0 -53
  54. package/dist/voice/tts-openai.js +0 -70
  55. package/dist/voice/tts-openai.test.js +0 -138
  56. package/dist/voice/types.test.js +0 -90
@@ -50,11 +50,11 @@ vi.mock('./task-sync-engine.js', () => {
50
50
  // ---------------------------------------------------------------------------
51
51
  function makeCtx() {
52
52
  return {
53
- guild: {},
53
+ guild: { id: 'guild-current' },
54
54
  client: {
55
55
  channels: {
56
56
  cache: {
57
- get: () => undefined,
57
+ get: vi.fn(() => undefined),
58
58
  },
59
59
  },
60
60
  },
@@ -62,8 +62,8 @@ function makeCtx() {
62
62
  messageId: 'msg-current',
63
63
  };
64
64
  }
65
- function makeStore() {
66
- const defaultTask = (id) => ({
65
+ function makeTask(id, overrides = {}) {
66
+ return {
67
67
  id,
68
68
  title: 'Test task',
69
69
  description: 'A test',
@@ -76,34 +76,81 @@ function makeStore() {
76
76
  comments: [],
77
77
  created_at: '2026-01-01T00:00:00Z',
78
78
  updated_at: '2026-01-01T00:00:00Z',
79
- });
79
+ ...overrides,
80
+ };
81
+ }
82
+ function makeStore(opts) {
83
+ const tasks = new Map((opts?.initialTasks ?? [
84
+ makeTask('ws-001', { title: 'First' }),
85
+ makeTask('ws-002', {
86
+ title: 'Second',
87
+ status: 'in_progress',
88
+ priority: 1,
89
+ external_ref: 'discord:222333444',
90
+ labels: [],
91
+ }),
92
+ ]).map((task) => [task.id, task]));
80
93
  return {
81
- get: vi.fn((id) => {
82
- if (id === 'ws-notfound')
83
- return undefined;
84
- return defaultTask(id);
94
+ get: vi.fn((id) => tasks.get(id)),
95
+ list: vi.fn((params) => (Array.from(tasks.values()).slice(0, params?.limit ?? 50))),
96
+ create: vi.fn((params) => {
97
+ const createTaskOverrides = typeof opts?.createTaskOverrides === 'function'
98
+ ? opts.createTaskOverrides(params)
99
+ : (opts?.createTaskOverrides ?? {});
100
+ const task = makeTask('ws-new', {
101
+ title: params.title,
102
+ description: params.description ?? '',
103
+ priority: params.priority ?? 2,
104
+ external_ref: '',
105
+ labels: params.labels ?? [],
106
+ ...createTaskOverrides,
107
+ });
108
+ tasks.set(task.id, task);
109
+ return task;
110
+ }),
111
+ update: vi.fn((id, params) => {
112
+ const prev = tasks.get(id);
113
+ if (!prev)
114
+ throw new Error(`task not found: ${id}`);
115
+ const updated = makeTask(id, {
116
+ ...prev,
117
+ ...(params.title !== undefined ? { title: params.title } : {}),
118
+ ...(params.description !== undefined ? { description: params.description } : {}),
119
+ ...(params.priority !== undefined ? { priority: params.priority } : {}),
120
+ ...(params.status !== undefined ? { status: params.status } : {}),
121
+ ...(params.owner !== undefined ? { owner: params.owner } : {}),
122
+ ...(params.externalRef !== undefined ? { external_ref: params.externalRef } : {}),
123
+ ...(params.threadOriginGuild !== undefined ? { thread_origin_guild: params.threadOriginGuild } : {}),
124
+ updated_at: '2026-01-02T00:00:00Z',
125
+ });
126
+ tasks.set(id, updated);
127
+ return updated;
128
+ }),
129
+ close: vi.fn((id) => {
130
+ const prev = tasks.get(id);
131
+ if (!prev)
132
+ throw new Error(`task not found: ${id}`);
133
+ const updated = makeTask(id, {
134
+ ...prev,
135
+ status: 'closed',
136
+ closed_at: '2026-01-02T00:00:00Z',
137
+ updated_at: '2026-01-02T00:00:00Z',
138
+ });
139
+ tasks.set(id, updated);
140
+ return updated;
141
+ }),
142
+ addLabel: vi.fn((id, label) => {
143
+ const prev = tasks.get(id);
144
+ if (!prev)
145
+ throw new Error(`task not found: ${id}`);
146
+ const updated = makeTask(id, {
147
+ ...prev,
148
+ labels: [...new Set([...(prev.labels ?? []), label])],
149
+ updated_at: '2026-01-02T00:00:00Z',
150
+ });
151
+ tasks.set(id, updated);
152
+ return updated;
85
153
  }),
86
- list: vi.fn(() => [
87
- { id: 'ws-001', title: 'First', status: 'open', priority: 2 },
88
- { id: 'ws-002', title: 'Second', status: 'in_progress', priority: 1 },
89
- ]),
90
- create: vi.fn((params) => ({
91
- id: 'ws-new',
92
- title: params.title,
93
- description: params.description ?? '',
94
- status: 'open',
95
- priority: params.priority ?? 2,
96
- issue_type: 'task',
97
- owner: '',
98
- external_ref: '',
99
- labels: params.labels ?? [],
100
- comments: [],
101
- created_at: '2026-01-01T00:00:00Z',
102
- updated_at: '2026-01-01T00:00:00Z',
103
- })),
104
- update: vi.fn((id) => defaultTask(id)),
105
- close: vi.fn((id) => ({ ...defaultTask(id), status: 'closed' })),
106
- addLabel: vi.fn((id) => defaultTask(id)),
107
154
  reload: vi.fn(async () => ({ added: [], updated: [], removed: [] })),
108
155
  };
109
156
  }
@@ -137,11 +184,19 @@ describe('TASK_ACTION_TYPES', () => {
137
184
  });
138
185
  });
139
186
  describe('executeTaskAction', () => {
140
- it('taskCreate returns created task summary', async () => {
187
+ it('taskCreate returns a real Discord thread URL and structured thread metadata', async () => {
141
188
  const result = await executeTaskAction({ type: 'taskCreate', title: 'New task', priority: 1 }, makeCtx(), makeTaskCtx());
189
+ const success = result;
142
190
  expect(result.ok).toBe(true);
143
- expect(result.summary).toContain('ws-new');
144
- expect(result.summary).toContain('New task');
191
+ expect(success.summary).toContain('ws-new');
192
+ expect(success.summary).toContain('New task');
193
+ expect(success.summary).toContain('Thread: https://discord.com/channels/guild-current/thread-new');
194
+ expect(success.thread).toEqual({
195
+ externalRef: 'discord:thread-new',
196
+ threadId: 'thread-new',
197
+ threadGuildId: 'guild-current',
198
+ threadUrl: 'https://discord.com/channels/guild-current/thread-new',
199
+ });
145
200
  });
146
201
  it('taskCreate calls forumCountSync.requestUpdate', async () => {
147
202
  const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
@@ -157,28 +212,29 @@ describe('executeTaskAction', () => {
157
212
  createTaskThread.mockClear?.();
158
213
  const result = await executeTaskAction({ type: 'taskCreate', title: 'No thread please', tags: 'no-thread,feature' }, makeCtx(), makeTaskCtx());
159
214
  expect(result.ok).toBe(true);
215
+ expect(result.summary).not.toContain('Thread:');
216
+ expect(result.thread).toBeUndefined();
160
217
  expect(createTaskThread).not.toHaveBeenCalled();
161
218
  });
162
- it('taskCreate skips thread creation when task is already linked before direct lifecycle step', async () => {
219
+ it('taskCreate backfills missing origin guild data for already-linked tasks', async () => {
163
220
  const { createTaskThread } = await import('./thread-ops.js');
164
221
  createTaskThread.mockClear?.();
165
- const store = makeStore();
166
- store.get.mockImplementation((id) => ({
167
- id,
168
- title: 'Already linked',
169
- status: 'open',
170
- priority: 2,
171
- issue_type: 'task',
172
- owner: '',
173
- external_ref: 'discord:thread-existing',
174
- labels: ['feature'],
175
- comments: [],
176
- created_at: '2026-01-01T00:00:00Z',
177
- updated_at: '2026-01-01T00:00:00Z',
178
- }));
222
+ const store = makeStore({
223
+ createTaskOverrides: {
224
+ title: 'Already linked',
225
+ external_ref: 'discord:thread-existing',
226
+ },
227
+ });
179
228
  const result = await executeTaskAction({ type: 'taskCreate', title: 'Task already linked' }, makeCtx(), makeTaskCtx({ store: store }));
180
229
  expect(result.ok).toBe(true);
181
- expect(result.summary).toContain('thread linked');
230
+ expect(result.summary).toContain('Thread: https://discord.com/channels/guild-current/thread-existing');
231
+ expect(result.thread).toEqual({
232
+ externalRef: 'discord:thread-existing',
233
+ threadId: 'thread-existing',
234
+ threadGuildId: 'guild-current',
235
+ threadUrl: 'https://discord.com/channels/guild-current/thread-existing',
236
+ });
237
+ expect(store.update).toHaveBeenCalledWith('ws-new', { threadOriginGuild: 'guild-current' });
182
238
  expect(createTaskThread).not.toHaveBeenCalled();
183
239
  });
184
240
  it('taskUpdate returns updated summary', async () => {
@@ -301,9 +357,65 @@ describe('executeTaskAction', () => {
301
357
  it('taskShow returns task details', async () => {
302
358
  const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx());
303
359
  expect(result.ok).toBe(true);
304
- expect(result.summary).toContain('Test task');
360
+ expect(result.summary).toContain('First');
305
361
  expect(result.summary).toContain('ws-001');
306
362
  });
363
+ it('taskShow shows External ref for linked tasks even without canonical thread URL data', async () => {
364
+ const store = makeStore({
365
+ initialTasks: [
366
+ makeTask('ws-001', {
367
+ title: 'Linked elsewhere',
368
+ external_ref: 'gh:123',
369
+ thread_origin_guild: undefined,
370
+ }),
371
+ ],
372
+ });
373
+ const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx({ store: store }));
374
+ expect(result.ok).toBe(true);
375
+ expect(result.summary).toContain('External ref: gh:123');
376
+ expect(result.summary).not.toContain('Thread:');
377
+ expect(result.thread).toEqual({ externalRef: 'gh:123' });
378
+ });
379
+ it('taskShow only emits Thread when stored origin guild data exists', async () => {
380
+ const store = makeStore({
381
+ initialTasks: [
382
+ makeTask('ws-001', {
383
+ title: 'Thread-linked',
384
+ external_ref: 'discord:111222333',
385
+ thread_origin_guild: 'guild-stored',
386
+ }),
387
+ ],
388
+ });
389
+ const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx({ store: store }));
390
+ expect(result.ok).toBe(true);
391
+ expect(result.summary).toContain('External ref: discord:111222333');
392
+ expect(result.summary).toContain('Thread: https://discord.com/channels/guild-stored/111222333');
393
+ expect(result.thread).toEqual({
394
+ externalRef: 'discord:111222333',
395
+ threadId: '111222333',
396
+ threadGuildId: 'guild-stored',
397
+ threadUrl: 'https://discord.com/channels/guild-stored/111222333',
398
+ });
399
+ });
400
+ it('taskShow does not perform live Discord lookups', async () => {
401
+ const { resolveTasksForum } = await import('./thread-ops.js');
402
+ resolveTasksForum.mockClear?.();
403
+ const ctx = makeCtx();
404
+ const cacheGet = ctx.client.channels.cache.get;
405
+ const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, ctx, makeTaskCtx({
406
+ store: makeStore({
407
+ initialTasks: [
408
+ makeTask('ws-001', {
409
+ external_ref: 'discord:111222333',
410
+ thread_origin_guild: 'guild-stored',
411
+ }),
412
+ ],
413
+ }),
414
+ }));
415
+ expect(result.ok).toBe(true);
416
+ expect(resolveTasksForum).not.toHaveBeenCalled();
417
+ expect(cacheGet).not.toHaveBeenCalled();
418
+ });
307
419
  it('taskShow fails for unknown task', async () => {
308
420
  const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-notfound' }, makeCtx(), makeTaskCtx());
309
421
  expect(result.ok).toBe(false);
@@ -4,6 +4,7 @@ import { autoTagTask } from './auto-tag.js';
4
4
  import { taskThreadCache } from './thread-cache.js';
5
5
  import { resolveTaskId, resolveTaskService, scheduleRepairSync, } from './task-action-mutation-helpers.js';
6
6
  import { ensureCreatedTaskThreadLink, syncClosedTaskThread, syncUpdatedTaskThread, } from './task-action-thread-sync.js';
7
+ import { getTaskActionThreadMetadata } from './task-action-runner-types.js';
7
8
  /** Pre-computed set for filtering status names from tag candidates. */
8
9
  const STATUS_NAME_SET = new Set(TASK_STATUSES);
9
10
  /**
@@ -73,10 +74,29 @@ export async function handleTaskCreate(action, ctx, taskCtx) {
73
74
  if (needsRepairSync) {
74
75
  scheduleRepairSync(taskCtx, task.id, ctx);
75
76
  }
77
+ let linkedTask = taskCtx.store.get(task.id) ?? task;
78
+ const storedThread = getTaskActionThreadMetadata(linkedTask);
79
+ const linkedThreadId = storedThread?.threadId ?? (threadId || undefined);
80
+ const guildId = ctx.guild.id?.trim() || undefined;
81
+ if (linkedThreadId && guildId && !linkedTask.thread_origin_guild) {
82
+ try {
83
+ linkedTask = taskService.update(task.id, {
84
+ ...(storedThread ? {} : { externalRef: `discord:${linkedThreadId}` }),
85
+ threadOriginGuild: guildId,
86
+ });
87
+ }
88
+ catch (err) {
89
+ taskCtx.log?.warn({ err, taskId: task.id, threadId: linkedThreadId, guildId }, 'tasks:thread origin guild update failed');
90
+ }
91
+ }
92
+ const thread = getTaskActionThreadMetadata(linkedTask);
76
93
  taskThreadCache.invalidate();
77
94
  taskCtx.forumCountSync?.requestUpdate();
78
- const threadNote = threadId ? ' (thread linked)' : '';
79
- return { ok: true, summary: `Task ${task.id} created: "${task.title}"${threadNote}` };
95
+ const summary = [
96
+ `Task ${task.id} created: "${task.title}"`,
97
+ ...(thread?.threadUrl ? [`Thread: ${thread.threadUrl}`] : []),
98
+ ].join('\n');
99
+ return { ok: true, summary, ...(thread ? { thread } : {}) };
80
100
  }
81
101
  export async function handleTaskUpdate(action, ctx, taskCtx) {
82
102
  const taskId = resolveTaskId(action);
@@ -1,5 +1,6 @@
1
1
  import { runTaskSync } from './task-sync.js';
2
2
  import { reloadTagMapInPlace } from './tag-map.js';
3
+ import { getTaskActionThreadMetadata } from './task-action-runner-types.js';
3
4
  function resolveTaskId(action) {
4
5
  return (action.taskId ?? '').trim();
5
6
  }
@@ -18,11 +19,16 @@ export async function handleTaskShow(action, _ctx, taskCtx) {
18
19
  ];
19
20
  if (task.owner)
20
21
  lines.push(`Owner: ${task.owner}`);
22
+ const thread = getTaskActionThreadMetadata(task);
23
+ if (thread)
24
+ lines.push(`External ref: ${thread.externalRef}`);
25
+ if (thread?.threadUrl)
26
+ lines.push(`Thread: ${thread.threadUrl}`);
21
27
  if (task.labels?.length)
22
28
  lines.push(`Labels: ${task.labels.join(', ')}`);
23
29
  if (task.description)
24
30
  lines.push(`\n${task.description.slice(0, 500)}`);
25
- return { ok: true, summary: lines.join('\n') };
31
+ return { ok: true, summary: lines.join('\n'), ...(thread ? { thread } : {}) };
26
32
  }
27
33
  export async function handleTaskList(action, _ctx, taskCtx) {
28
34
  const tasks = taskCtx.store.list({
@@ -1 +1,19 @@
1
- export {};
1
+ import { getThreadIdFromTask } from './thread-helpers.js';
2
+ export function buildDiscordThreadUrl(guildId, threadId) {
3
+ return `https://discord.com/channels/${guildId}/${threadId}`;
4
+ }
5
+ export function getTaskActionThreadMetadata(task) {
6
+ const externalRef = task.external_ref?.trim() ?? '';
7
+ if (!externalRef)
8
+ return undefined;
9
+ const threadId = getThreadIdFromTask(task) ?? undefined;
10
+ const threadGuildId = task.thread_origin_guild?.trim() || undefined;
11
+ return {
12
+ externalRef,
13
+ ...(threadId ? { threadId } : {}),
14
+ ...(threadGuildId ? { threadGuildId } : {}),
15
+ ...(threadId && threadGuildId
16
+ ? { threadUrl: buildDiscordThreadUrl(threadGuildId, threadId) }
17
+ : {}),
18
+ };
19
+ }