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.
- package/.env.example +4 -6
- package/.env.example.full +13 -32
- package/README.md +1 -1
- package/dist/cli/dashboard.test.js +0 -4
- package/dist/cli/init-wizard.js +4 -8
- package/dist/cli/init-wizard.test.js +4 -10
- package/dist/config.js +2 -42
- package/dist/config.test.js +8 -72
- package/dist/dashboard/server.js +1 -5
- package/dist/dashboard/server.test.js +3 -6
- package/dist/discord/actions.js +112 -6
- package/dist/discord/actions.test.js +117 -1
- package/dist/discord/help-command.js +1 -1
- package/dist/discord/message-coordinator.js +3 -8
- package/dist/discord/models-command.js +1 -1
- package/dist/discord/reaction-handler.js +2 -2
- package/dist/discord/reaction-handler.test.js +55 -0
- package/dist/discord/verify-push.js +31 -36
- package/dist/discord/verify-push.test.js +34 -6
- package/dist/discord/voice-command.js +1 -31
- package/dist/discord/voice-command.test.js +21 -259
- package/dist/discord/voice-status-command.js +3 -22
- package/dist/discord/voice-status-command.test.js +16 -124
- package/dist/discord-followup.test.js +133 -0
- package/dist/health/config-doctor.js +5 -27
- package/dist/health/config-doctor.test.js +1 -4
- package/dist/index.js +1 -28
- package/dist/runtime-overrides.js +2 -3
- package/dist/runtime-overrides.test.js +27 -193
- package/dist/tasks/store.js +10 -6
- package/dist/tasks/store.test.js +44 -0
- package/dist/tasks/task-action-executor.test.js +162 -50
- package/dist/tasks/task-action-mutations.js +22 -2
- package/dist/tasks/task-action-read-ops.js +7 -1
- package/dist/tasks/task-action-runner-types.js +19 -1
- package/dist/voice/audio-pipeline.js +145 -298
- package/docs/configuration.md +4 -9
- package/docs/official-docs.md +6 -9
- package/docs/runtime-switching.md +1 -1
- package/package.json +1 -1
- package/dist/voice/audio-pipeline.test.js +0 -1100
- package/dist/voice/stt-deepgram.js +0 -154
- package/dist/voice/stt-deepgram.test.js +0 -275
- package/dist/voice/stt-factory.js +0 -42
- package/dist/voice/stt-factory.test.js +0 -45
- package/dist/voice/stt-openai.js +0 -156
- package/dist/voice/stt-openai.test.js +0 -281
- package/dist/voice/tts-cartesia.js +0 -169
- package/dist/voice/tts-cartesia.test.js +0 -228
- package/dist/voice/tts-deepgram.js +0 -84
- package/dist/voice/tts-deepgram.test.js +0 -220
- package/dist/voice/tts-factory.js +0 -52
- package/dist/voice/tts-factory.test.js +0 -53
- package/dist/voice/tts-openai.js +0 -70
- package/dist/voice/tts-openai.test.js +0 -138
- 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(['
|
|
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
|
-
|
|
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:
|
|
571
|
+
id: 'missing-secret:DISCOCLAW_VOICE_ENABLED:GEMINI_API_KEY',
|
|
594
572
|
severity: 'error',
|
|
595
|
-
message:
|
|
596
|
-
recommendation:
|
|
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:
|
|
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
|
-
|
|
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 = ['
|
|
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
|
|
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
|
-
|
|
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
|
|
19
|
-
|
|
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
|
|
27
|
-
await fs.rm(
|
|
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
|
-
|
|
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('
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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('
|
|
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:
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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('
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
});
|
package/dist/tasks/store.js
CHANGED
|
@@ -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
|
-
...(
|
|
233
|
-
...(
|
|
234
|
-
...(
|
|
235
|
-
...(
|
|
236
|
-
...(
|
|
237
|
-
...(
|
|
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);
|
package/dist/tasks/store.test.js
CHANGED
|
@@ -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' });
|