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
@@ -13,7 +13,7 @@ export const KNOWN_RUNTIMES = new Set([
13
13
  'openai',
14
14
  'openrouter',
15
15
  ]);
16
- const KNOWN_RUNTIME_OVERRIDE_KEYS = new Set(['ttsVoice', 'voiceRuntime', 'fastRuntime']);
16
+ const KNOWN_RUNTIME_OVERRIDE_KEYS = new Set(['voiceRuntime', 'fastRuntime']);
17
17
  const DEPRECATED_ENV_VARS = {
18
18
  DISCOCLAW_FAST_RUNTIME: {
19
19
  recommendation: "Replace DISCOCLAW_FAST_RUNTIME with '!models set fast <model>' and remove the env var once the runtime override is no longer needed.",
@@ -146,8 +146,6 @@ async function readRuntimeOverridesFileState(filePath) {
146
146
  }
147
147
  const obj = parsed;
148
148
  const overrides = {};
149
- if (typeof obj['ttsVoice'] === 'string')
150
- overrides.ttsVoice = obj['ttsVoice'];
151
149
  if (typeof obj['voiceRuntime'] === 'string')
152
150
  overrides.voiceRuntime = obj['voiceRuntime'];
153
151
  if (typeof obj['fastRuntime'] === 'string')
@@ -568,32 +566,12 @@ export function detectMissingSecrets(ctx) {
568
566
  }
569
567
  const voiceEnabled = parseBoolean(ctx.env.DISCOCLAW_VOICE_ENABLED, false);
570
568
  if (voiceEnabled) {
571
- const sttProvider = trimValue(ctx.env.DISCOCLAW_STT_PROVIDER) ?? 'deepgram';
572
- const ttsProvider = trimValue(ctx.env.DISCOCLAW_TTS_PROVIDER) ?? 'cartesia';
573
- const voiceSecretTargets = [];
574
- if (sttProvider === 'deepgram') {
575
- voiceSecretTargets.push({ label: 'DISCOCLAW_STT_PROVIDER', provider: sttProvider, secretKey: 'DEEPGRAM_API_KEY' });
576
- }
577
- else if (sttProvider === 'openai') {
578
- voiceSecretTargets.push({ label: 'DISCOCLAW_STT_PROVIDER', provider: sttProvider, secretKey: 'OPENAI_API_KEY' });
579
- }
580
- if (ttsProvider === 'cartesia') {
581
- voiceSecretTargets.push({ label: 'DISCOCLAW_TTS_PROVIDER', provider: ttsProvider, secretKey: 'CARTESIA_API_KEY' });
582
- }
583
- else if (ttsProvider === 'deepgram') {
584
- voiceSecretTargets.push({ label: 'DISCOCLAW_TTS_PROVIDER', provider: ttsProvider, secretKey: 'DEEPGRAM_API_KEY' });
585
- }
586
- else if (ttsProvider === 'openai') {
587
- voiceSecretTargets.push({ label: 'DISCOCLAW_TTS_PROVIDER', provider: ttsProvider, secretKey: 'OPENAI_API_KEY' });
588
- }
589
- for (const target of voiceSecretTargets) {
590
- if (hasSecret(ctx.env, target.secretKey))
591
- continue;
569
+ if (!hasSecret(ctx.env, 'GEMINI_API_KEY')) {
592
570
  findings.push({
593
- id: `missing-secret:${target.label}:${target.secretKey}`,
571
+ id: 'missing-secret:DISCOCLAW_VOICE_ENABLED:GEMINI_API_KEY',
594
572
  severity: 'error',
595
- message: `${target.label}=${target.provider} requires ${target.secretKey}, but it is not set.`,
596
- recommendation: `Set ${target.secretKey} or switch ${target.label} to a provider that is already configured.`,
573
+ message: 'DISCOCLAW_VOICE_ENABLED=1 requires GEMINI_API_KEY, but it is not set.',
574
+ recommendation: 'Set GEMINI_API_KEY or disable voice on this install.',
597
575
  autoFixable: false,
598
576
  });
599
577
  }
@@ -242,8 +242,6 @@ describe('detectMissingSecrets', () => {
242
242
  await writeEnv(cwd, [
243
243
  'PRIMARY_RUNTIME=openrouter',
244
244
  'DISCOCLAW_VOICE_ENABLED=1',
245
- 'DISCOCLAW_STT_PROVIDER=deepgram',
246
- 'DISCOCLAW_TTS_PROVIDER=cartesia',
247
245
  'DISCOCLAW_COLD_STORAGE_ENABLED=1',
248
246
  'DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1',
249
247
  'IMAGEGEN_DEFAULT_MODEL=imagen-4.0-generate-001',
@@ -256,8 +254,7 @@ describe('detectMissingSecrets', () => {
256
254
  expect(findings.map((finding) => finding.id)).toEqual([
257
255
  'missing-secret:PRIMARY_RUNTIME:OPENROUTER_API_KEY',
258
256
  'missing-secret:runtime-overrides.voiceRuntime:OPENAI_API_KEY',
259
- 'missing-secret:DISCOCLAW_STT_PROVIDER:DEEPGRAM_API_KEY',
260
- 'missing-secret:DISCOCLAW_TTS_PROVIDER:CARTESIA_API_KEY',
257
+ 'missing-secret:DISCOCLAW_VOICE_ENABLED:GEMINI_API_KEY',
261
258
  'missing-secret:DISCOCLAW_COLD_STORAGE_ENABLED:COLD_STORAGE_API_KEY-or-OPENAI_API_KEY',
262
259
  'missing-secret:DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN:OPENAI_API_KEY-or-IMAGEGEN_GEMINI_API_KEY',
263
260
  'missing-secret:IMAGEGEN_DEFAULT_MODEL:IMAGEGEN_GEMINI_API_KEY',
package/dist/index.js CHANGED
@@ -1105,9 +1105,6 @@ if (currentOverridesState.fastRuntime) {
1105
1105
  log.warn({ fastRuntime: currentOverridesState.fastRuntime, availableRuntimes: runtimeRegistry.list() }, 'runtime-overrides: fastRuntime is not a registered runtime; ignoring');
1106
1106
  }
1107
1107
  }
1108
- if (currentOverridesState.ttsVoice) {
1109
- log.info({ ttsVoice: currentOverridesState.ttsVoice }, 'runtime-overrides: ttsVoice override will be applied');
1110
- }
1111
1108
  // Track which roles have active file-backed overrides (used by !models show).
1112
1109
  // Only mark as override if the stored value differs from env defaults.
1113
1110
  const overrideSources = detectOverrideSources(currentModelConfig, envModelDefaults);
@@ -1261,8 +1258,6 @@ const botParams = {
1261
1258
  spawnCtx: undefined,
1262
1259
  voiceCtx: undefined,
1263
1260
  voiceStatusCtx: undefined,
1264
- setTtsVoice: undefined,
1265
- getTtsVoice: undefined,
1266
1261
  configCtx: undefined,
1267
1262
  deferOpts: undefined,
1268
1263
  messageHistoryBudget,
@@ -1325,13 +1320,8 @@ const botParams = {
1325
1320
  voiceEnabled: cfg.voiceEnabled,
1326
1321
  voiceAutoJoin: cfg.voiceAutoJoin,
1327
1322
  voiceModelCtx: voiceModelRef,
1328
- voiceSttProvider: cfg.voiceSttProvider,
1329
- voiceTtsProvider: cfg.voiceTtsProvider,
1330
1323
  voiceHomeChannel: cfg.voiceHomeChannel,
1331
- deepgramApiKey: cfg.deepgramApiKey,
1332
- deepgramSttModel: cfg.deepgramSttModel,
1333
- deepgramTtsVoice: cfg.deepgramTtsVoice,
1334
- cartesiaApiKey: cfg.cartesiaApiKey,
1324
+ geminiApiKey: cfg.geminiApiKey,
1335
1325
  botStatus: cfg.botStatus,
1336
1326
  botActivity: cfg.botActivity,
1337
1327
  botActivityType: cfg.botActivityType,
@@ -2090,19 +2080,10 @@ if (taskCtx) {
2090
2080
  log,
2091
2081
  voiceConfig: {
2092
2082
  enabled: cfg.voiceEnabled,
2093
- sttProvider: cfg.voiceSttProvider,
2094
- ttsProvider: cfg.voiceTtsProvider,
2095
2083
  homeChannel: cfg.voiceHomeChannel,
2096
- deepgramApiKey: cfg.deepgramApiKey,
2097
- deepgramSttModel: cfg.deepgramSttModel,
2098
- deepgramTtsVoice: overrides.ttsVoice ?? cfg.deepgramTtsVoice,
2099
- deepgramTtsSpeed: cfg.deepgramTtsSpeed,
2100
- cartesiaApiKey: cfg.cartesiaApiKey,
2101
- openaiApiKey: cfg.openaiApiKey,
2102
2084
  },
2103
2085
  allowedUserIds: allowUserIds,
2104
2086
  createDecoder: opusDecoderFactory,
2105
- voiceProvider: cfg.voicePipelineProvider,
2106
2087
  geminiApiKey: cfg.geminiApiKey,
2107
2088
  enabledTools: runtimeTools,
2108
2089
  invokeAi: voiceInvokeAi,
@@ -2134,14 +2115,6 @@ if (taskCtx) {
2134
2115
  },
2135
2116
  });
2136
2117
  botParams.voiceStatusCtx = { voiceManager };
2137
- botParams.setTtsVoice = async (voice) => {
2138
- const count = await audioPipeline.setTtsVoice(voice);
2139
- botParams.deepgramTtsVoice = voice;
2140
- currentOverridesState.ttsVoice = voice;
2141
- saveOverrides(overridesPath, currentOverridesState).catch((err) => log.warn({ err, voice }, 'runtime-overrides: ttsVoice save failed'));
2142
- return count;
2143
- };
2144
- botParams.getTtsVoice = () => audioPipeline.ttsVoice;
2145
2118
  if (cfg.discordActionsVoice) {
2146
2119
  botParams.voiceCtx = { voiceManager };
2147
2120
  log.info('voice:action context initialized with audio pipeline');
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { parseRuntimeNameForPlacement } from './runtime/runtime-path-contract.js';
4
4
  const OVERRIDES_FILENAME = 'runtime-overrides.json';
5
- const KNOWN_OVERRIDE_KEYS = ['ttsVoice', 'voiceRuntime', 'fastRuntime'];
5
+ const KNOWN_OVERRIDE_KEYS = ['voiceRuntime', 'fastRuntime'];
6
6
  export function normalizeRuntimeOverrides(overrides) {
7
7
  const next = { ...overrides };
8
8
  let changed = false;
@@ -57,8 +57,6 @@ export async function loadOverrides(filePath, onWarn) {
57
57
  }
58
58
  const obj = parsed;
59
59
  const overrides = {};
60
- if (typeof obj['ttsVoice'] === 'string')
61
- overrides.ttsVoice = obj['ttsVoice'];
62
60
  if (typeof obj['voiceRuntime'] === 'string')
63
61
  overrides.voiceRuntime = obj['voiceRuntime'];
64
62
  if (typeof obj['fastRuntime'] === 'string')
@@ -75,6 +73,7 @@ export async function saveOverrides(filePath, overrides) {
75
73
  const tmpPath = `${filePath}.tmp.${process.pid}`;
76
74
  try {
77
75
  const preserved = await readExistingRawObject(filePath);
76
+ delete preserved['ttsVoice'];
78
77
  for (const key of KNOWN_OVERRIDE_KEYS) {
79
78
  delete preserved[key];
80
79
  }
@@ -1,229 +1,63 @@
1
1
  import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
2
  import os from 'node:os';
3
+ import path from 'node:path';
4
4
  import { afterEach, describe, expect, it } from 'vitest';
5
- import { loadOverrides, normalizeRuntimeOverrides, resolveOverridesPath, saveOverrides, } from './runtime-overrides.js';
5
+ import { loadOverrides, normalizeRuntimeOverrides, resolveOverridesPath, saveOverrides } from './runtime-overrides.js';
6
6
  async function tmpDir() {
7
7
  return fs.mkdtemp(path.join(os.tmpdir(), 'runtime-overrides-'));
8
8
  }
9
9
  describe('resolveOverridesPath', () => {
10
10
  it('uses configured data dir when provided', () => {
11
- const out = resolveOverridesPath('/var/lib/discoclaw', '/repo');
12
- expect(out).toBe(path.join('/var/lib/discoclaw', 'runtime-overrides.json'));
13
- });
14
- it('falls back to <projectRoot>/data when data dir is empty string', () => {
15
- const out = resolveOverridesPath('', '/repo');
16
- expect(out).toBe(path.join('/repo', 'data', 'runtime-overrides.json'));
11
+ expect(resolveOverridesPath('/var/lib/discoclaw', '/repo')).toBe('/var/lib/discoclaw/runtime-overrides.json');
17
12
  });
18
- it('falls back to <projectRoot>/data when data dir is undefined', () => {
19
- const out = resolveOverridesPath(undefined, '/repo');
20
- expect(out).toBe(path.join('/repo', 'data', 'runtime-overrides.json'));
13
+ it('falls back to <projectRoot>/data when data dir is absent', () => {
14
+ expect(resolveOverridesPath(undefined, '/repo')).toBe('/repo/data/runtime-overrides.json');
21
15
  });
22
16
  });
23
- describe('loadOverrides', () => {
17
+ describe('loadOverrides and saveOverrides', () => {
24
18
  const dirs = [];
25
19
  afterEach(async () => {
26
- for (const d of dirs) {
27
- await fs.rm(d, { recursive: true, force: true });
20
+ for (const dir of dirs) {
21
+ await fs.rm(dir, { recursive: true, force: true });
28
22
  }
29
23
  dirs.length = 0;
30
24
  });
31
- it('returns empty object when file does not exist', async () => {
32
- const dir = await tmpDir();
33
- dirs.push(dir);
34
- const result = await loadOverrides(path.join(dir, 'runtime-overrides.json'));
35
- expect(result).toEqual({});
36
- });
37
- it('loads ttsVoice from a valid file', async () => {
38
- const dir = await tmpDir();
39
- dirs.push(dir);
40
- const filePath = path.join(dir, 'runtime-overrides.json');
41
- await fs.writeFile(filePath, JSON.stringify({ ttsVoice: 'aura-2-luna-en' }), 'utf-8');
42
- const result = await loadOverrides(filePath);
43
- expect(result).toEqual({ ttsVoice: 'aura-2-luna-en' });
44
- });
45
- it('returns empty object for corrupt JSON and calls onWarn', async () => {
46
- const dir = await tmpDir();
47
- dirs.push(dir);
48
- const filePath = path.join(dir, 'runtime-overrides.json');
49
- await fs.writeFile(filePath, 'not-valid-json', 'utf-8');
50
- const warnings = [];
51
- const result = await loadOverrides(filePath, (msg) => warnings.push(msg));
52
- expect(result).toEqual({});
53
- expect(warnings).toHaveLength(1);
54
- expect(warnings[0]).toMatch(/corrupt JSON/);
55
- });
56
- it('returns empty object when JSON root is an array and calls onWarn', async () => {
57
- const dir = await tmpDir();
58
- dirs.push(dir);
59
- const filePath = path.join(dir, 'runtime-overrides.json');
60
- await fs.writeFile(filePath, JSON.stringify(['opus']), 'utf-8');
61
- const warnings = [];
62
- const result = await loadOverrides(filePath, (msg) => warnings.push(msg));
63
- expect(result).toEqual({});
64
- expect(warnings).toHaveLength(1);
65
- expect(warnings[0]).toMatch(/not an object/);
66
- });
67
- it('returns empty object when JSON root is a primitive and calls onWarn', async () => {
68
- const dir = await tmpDir();
69
- dirs.push(dir);
70
- const filePath = path.join(dir, 'runtime-overrides.json');
71
- await fs.writeFile(filePath, JSON.stringify(42), 'utf-8');
72
- const warnings = [];
73
- const result = await loadOverrides(filePath, (msg) => warnings.push(msg));
74
- expect(result).toEqual({});
75
- expect(warnings).toHaveLength(1);
76
- expect(warnings[0]).toMatch(/not an object/);
77
- });
78
- it('silently drops unknown top-level fields (including legacy models key)', async () => {
79
- const dir = await tmpDir();
80
- dirs.push(dir);
81
- const filePath = path.join(dir, 'runtime-overrides.json');
82
- await fs.writeFile(filePath, JSON.stringify({ models: { chat: 'sonnet' }, unknownField: 'x' }), 'utf-8');
83
- const result = await loadOverrides(filePath);
84
- expect(result).toEqual({});
85
- });
86
- it('loads ttsVoice field correctly', async () => {
25
+ it('returns empty object when the file does not exist', async () => {
87
26
  const dir = await tmpDir();
88
27
  dirs.push(dir);
89
- const filePath = path.join(dir, 'runtime-overrides.json');
90
- await fs.writeFile(filePath, JSON.stringify({ ttsVoice: 'aura-2-asteria-en' }), 'utf-8');
91
- const result = await loadOverrides(filePath);
92
- expect(result).toEqual({ ttsVoice: 'aura-2-asteria-en' });
28
+ await expect(loadOverrides(path.join(dir, 'runtime-overrides.json'))).resolves.toEqual({});
93
29
  });
94
- it('silently drops ttsVoice when it is not a string', async () => {
30
+ it('round-trips managed runtime overrides', async () => {
95
31
  const dir = await tmpDir();
96
32
  dirs.push(dir);
97
33
  const filePath = path.join(dir, 'runtime-overrides.json');
98
- await fs.writeFile(filePath, JSON.stringify({ ttsVoice: 42 }), 'utf-8');
99
- const result = await loadOverrides(filePath);
100
- expect(result).toEqual({});
101
- });
102
- it('loads voiceRuntime field correctly', async () => {
103
- const dir = await tmpDir();
104
- dirs.push(dir);
105
- const filePath = path.join(dir, 'runtime-overrides.json');
106
- await fs.writeFile(filePath, JSON.stringify({ voiceRuntime: 'gemini-api' }), 'utf-8');
107
- const result = await loadOverrides(filePath);
108
- expect(result).toEqual({ voiceRuntime: 'gemini-api' });
34
+ await saveOverrides(filePath, { voiceRuntime: 'claude-api', fastRuntime: 'codex-cli' });
35
+ await expect(loadOverrides(filePath)).resolves.toEqual({
36
+ voiceRuntime: 'claude-api',
37
+ fastRuntime: 'codex-cli',
38
+ });
109
39
  });
110
- it('silently drops voiceRuntime when it is not a string', async () => {
40
+ it('drops legacy ttsVoice while preserving unrelated keys', async () => {
111
41
  const dir = await tmpDir();
112
42
  dirs.push(dir);
113
43
  const filePath = path.join(dir, 'runtime-overrides.json');
114
- await fs.writeFile(filePath, JSON.stringify({ voiceRuntime: 123 }), 'utf-8');
115
- const result = await loadOverrides(filePath);
116
- expect(result).toEqual({});
44
+ await fs.writeFile(filePath, JSON.stringify({ customFlag: true, ttsVoice: 'old-voice', voiceRuntime: 'gemini-api' }), 'utf-8');
45
+ await saveOverrides(filePath, { fastRuntime: 'codex-cli' });
46
+ const raw = JSON.parse(await fs.readFile(filePath, 'utf-8'));
47
+ expect(raw).toEqual({ customFlag: true, fastRuntime: 'codex-cli' });
117
48
  });
118
49
  });
119
50
  describe('normalizeRuntimeOverrides', () => {
120
- it('canonicalizes accepted voice runtime aliases', () => {
121
- const result = normalizeRuntimeOverrides({ voiceRuntime: 'Anthropic' });
122
- expect(result).toEqual({
123
- overrides: { voiceRuntime: 'claude-api' },
51
+ it('canonicalizes accepted runtime aliases', () => {
52
+ expect(normalizeRuntimeOverrides({ voiceRuntime: 'Anthropic', fastRuntime: 'claude_code' })).toEqual({
53
+ overrides: { voiceRuntime: 'claude-api', fastRuntime: 'claude-cli' },
124
54
  changed: true,
125
55
  });
126
56
  });
127
- it('canonicalizes accepted fast runtime aliases', () => {
128
- const result = normalizeRuntimeOverrides({ fastRuntime: 'claude_code' });
129
- expect(result).toEqual({
130
- overrides: { fastRuntime: 'claude-cli' },
131
- changed: true,
132
- });
133
- });
134
- it('leaves invalid or already-canonical runtime values unchanged', () => {
135
- const result = normalizeRuntimeOverrides({
136
- voiceRuntime: 'claude-api',
137
- fastRuntime: 'not-a-runtime',
138
- ttsVoice: 'aura-2-asteria-en',
139
- });
140
- expect(result).toEqual({
141
- overrides: {
142
- voiceRuntime: 'claude-api',
143
- fastRuntime: 'not-a-runtime',
144
- ttsVoice: 'aura-2-asteria-en',
145
- },
57
+ it('leaves invalid or canonical runtime values unchanged', () => {
58
+ expect(normalizeRuntimeOverrides({ voiceRuntime: 'claude-api', fastRuntime: 'not-a-runtime' })).toEqual({
59
+ overrides: { voiceRuntime: 'claude-api', fastRuntime: 'not-a-runtime' },
146
60
  changed: false,
147
61
  });
148
62
  });
149
63
  });
150
- describe('saveOverrides', () => {
151
- const dirs = [];
152
- afterEach(async () => {
153
- for (const d of dirs) {
154
- await fs.rm(d, { recursive: true, force: true });
155
- }
156
- dirs.length = 0;
157
- });
158
- it('writes overrides and reads them back correctly', async () => {
159
- const dir = await tmpDir();
160
- dirs.push(dir);
161
- const filePath = path.join(dir, 'runtime-overrides.json');
162
- await saveOverrides(filePath, { ttsVoice: 'aura-2-asteria-en', voiceRuntime: 'claude-api' });
163
- const raw = await fs.readFile(filePath, 'utf-8');
164
- expect(JSON.parse(raw)).toEqual({ ttsVoice: 'aura-2-asteria-en', voiceRuntime: 'claude-api' });
165
- });
166
- it('writes ttsVoice and reads it back correctly', async () => {
167
- const dir = await tmpDir();
168
- dirs.push(dir);
169
- const filePath = path.join(dir, 'runtime-overrides.json');
170
- await saveOverrides(filePath, { ttsVoice: 'aura-2-asteria-en' });
171
- const result = await loadOverrides(filePath);
172
- expect(result).toEqual({ ttsVoice: 'aura-2-asteria-en' });
173
- });
174
- it('round-trips ttsVoice + voiceRuntime through save and load', async () => {
175
- const dir = await tmpDir();
176
- dirs.push(dir);
177
- const filePath = path.join(dir, 'runtime-overrides.json');
178
- const original = {
179
- ttsVoice: 'aura-2-luna-en',
180
- voiceRuntime: 'gemini-api',
181
- };
182
- await saveOverrides(filePath, original);
183
- const result = await loadOverrides(filePath);
184
- expect(result).toEqual(original);
185
- });
186
- it('creates the parent directory when it does not exist', async () => {
187
- const dir = await tmpDir();
188
- dirs.push(dir);
189
- const filePath = path.join(dir, 'subdir', 'runtime-overrides.json');
190
- await saveOverrides(filePath, { voiceRuntime: 'claude-api' });
191
- const raw = await fs.readFile(filePath, 'utf-8');
192
- expect(JSON.parse(raw)).toEqual({ voiceRuntime: 'claude-api' });
193
- });
194
- it('leaves no tmp file behind after a successful write', async () => {
195
- const dir = await tmpDir();
196
- dirs.push(dir);
197
- const filePath = path.join(dir, 'runtime-overrides.json');
198
- await saveOverrides(filePath, { ttsVoice: 'aura-2-asteria-en' });
199
- const files = await fs.readdir(dir);
200
- const tmpFiles = files.filter((f) => f.includes('.tmp.'));
201
- expect(tmpFiles).toHaveLength(0);
202
- });
203
- it('overwrites an existing overrides file', async () => {
204
- const dir = await tmpDir();
205
- dirs.push(dir);
206
- const filePath = path.join(dir, 'runtime-overrides.json');
207
- await saveOverrides(filePath, { ttsVoice: 'voice-a' });
208
- await saveOverrides(filePath, { ttsVoice: 'voice-b' });
209
- const raw = await fs.readFile(filePath, 'utf-8');
210
- expect(JSON.parse(raw)).toEqual({ ttsVoice: 'voice-b' });
211
- });
212
- it('preserves unknown keys while replacing known runtime override fields', async () => {
213
- const dir = await tmpDir();
214
- dirs.push(dir);
215
- const filePath = path.join(dir, 'runtime-overrides.json');
216
- await fs.writeFile(filePath, JSON.stringify({ customFlag: true, voiceRuntime: 'claude-api', ttsVoice: 'voice-a' }), 'utf-8');
217
- await saveOverrides(filePath, { fastRuntime: 'openrouter' });
218
- const raw = await fs.readFile(filePath, 'utf-8');
219
- expect(JSON.parse(raw)).toEqual({ customFlag: true, fastRuntime: 'openrouter' });
220
- });
221
- it('writes an empty overrides object', async () => {
222
- const dir = await tmpDir();
223
- dirs.push(dir);
224
- const filePath = path.join(dir, 'runtime-overrides.json');
225
- await saveOverrides(filePath, {});
226
- const raw = await fs.readFile(filePath, 'utf-8');
227
- expect(JSON.parse(raw)).toEqual({});
228
- });
229
- });
@@ -227,14 +227,18 @@ export class TaskStore extends EventEmitter {
227
227
  if (!prev)
228
228
  throw new Error(`task not found: ${id}`);
229
229
  const now = new Date().toISOString();
230
+ const updateParams = params;
230
231
  const updated = {
231
232
  ...prev,
232
- ...(params.title !== undefined && { title: params.title }),
233
- ...(params.description !== undefined && { description: params.description }),
234
- ...(params.priority !== undefined && { priority: params.priority }),
235
- ...(params.status !== undefined && { status: params.status }),
236
- ...(params.owner !== undefined && { owner: params.owner }),
237
- ...(params.externalRef !== undefined && { external_ref: params.externalRef }),
233
+ ...(updateParams.title !== undefined && { title: updateParams.title }),
234
+ ...(updateParams.description !== undefined && { description: updateParams.description }),
235
+ ...(updateParams.priority !== undefined && { priority: updateParams.priority }),
236
+ ...(updateParams.status !== undefined && { status: updateParams.status }),
237
+ ...(updateParams.owner !== undefined && { owner: updateParams.owner }),
238
+ ...(updateParams.externalRef !== undefined && { external_ref: updateParams.externalRef }),
239
+ ...(updateParams.threadOriginGuild !== undefined && {
240
+ thread_origin_guild: updateParams.threadOriginGuild,
241
+ }),
238
242
  updated_at: now,
239
243
  };
240
244
  this.tasks.set(id, updated);
@@ -408,6 +408,50 @@ describe('TaskStore — persistence', () => {
408
408
  expect(loaded.close_reason).toBe('done');
409
409
  await fsp.default.unlink(path).catch(() => { });
410
410
  });
411
+ it('persists thread origin guild when update provides it', async () => {
412
+ const fsp = await import('node:fs/promises');
413
+ const path = '/tmp/discoclaw-test-store-thread-origin-guild.jsonl';
414
+ await fsp.default.unlink(path).catch(() => { });
415
+ const store1 = new TaskStore({ prefix: 'ws', persistPath: path });
416
+ const task = store1.create({ title: 'T' });
417
+ store1.update(task.id, {
418
+ externalRef: 'discord:123',
419
+ threadOriginGuild: 'guild-456',
420
+ });
421
+ await store1.flush();
422
+ const persisted = JSON.parse((await fsp.default.readFile(path, 'utf8')).trim());
423
+ expect(persisted.external_ref).toBe('discord:123');
424
+ expect(persisted.thread_origin_guild).toBe('guild-456');
425
+ const store2 = new TaskStore({ prefix: 'ws', persistPath: path });
426
+ await store2.load();
427
+ const loaded = store2.get(task.id);
428
+ expect(loaded?.thread_origin_guild).toBe('guild-456');
429
+ await fsp.default.unlink(path).catch(() => { });
430
+ });
431
+ it('keeps legacy tasks without thread origin guild readable and writable', async () => {
432
+ const fsp = await import('node:fs/promises');
433
+ const path = '/tmp/discoclaw-test-store-legacy-thread-origin-guild.jsonl';
434
+ await fsp.default.unlink(path).catch(() => { });
435
+ const legacyTask = {
436
+ id: 'ws-001',
437
+ title: 'Legacy task',
438
+ status: 'open',
439
+ external_ref: 'discord:123',
440
+ created_at: '2026-04-06T00:00:00.000Z',
441
+ updated_at: '2026-04-06T00:00:00.000Z',
442
+ };
443
+ await fsp.default.writeFile(path, `${JSON.stringify(legacyTask)}\n`, 'utf8');
444
+ const store = new TaskStore({ prefix: 'ws', persistPath: path });
445
+ await store.load();
446
+ const loaded = store.get('ws-001');
447
+ expect(loaded?.thread_origin_guild).toBeUndefined();
448
+ store.update('ws-001', { title: 'Legacy task updated' });
449
+ await store.flush();
450
+ const rewritten = JSON.parse((await fsp.default.readFile(path, 'utf8')).trim());
451
+ expect(rewritten.title).toBe('Legacy task updated');
452
+ expect(rewritten.thread_origin_guild).toBeUndefined();
453
+ await fsp.default.unlink(path).catch(() => { });
454
+ });
411
455
  it('is a no-op when no persistPath is configured', async () => {
412
456
  const store = new TaskStore({ prefix: 'ws' });
413
457
  store.create({ title: 'T' });