mod8-cli 0.2.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/CHANGELOG.md +87 -0
- package/LICENSE +21 -0
- package/README.md +239 -0
- package/bin/mod8.js +2 -0
- package/dist/cli.js +302 -0
- package/dist/commands/addProvider.js +105 -0
- package/dist/commands/all.js +158 -0
- package/dist/commands/chat.js +855 -0
- package/dist/commands/config.js +29 -0
- package/dist/commands/devAuthStatus.js +34 -0
- package/dist/commands/devHostAsk.js +51 -0
- package/dist/commands/devHostSystem.js +15 -0
- package/dist/commands/devResolve.js +54 -0
- package/dist/commands/devSimulate.js +235 -0
- package/dist/commands/devWorkAsk.js +55 -0
- package/dist/commands/intentRouting.js +280 -0
- package/dist/commands/keys.js +55 -0
- package/dist/commands/list.js +27 -0
- package/dist/commands/login.js +147 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/prompt.js +63 -0
- package/dist/commands/providers.js +30 -0
- package/dist/commands/verify.js +5 -0
- package/dist/input/compose.js +37 -0
- package/dist/input/files.js +49 -0
- package/dist/input/stdin.js +14 -0
- package/dist/providers/anthropic.js +115 -0
- package/dist/providers/displayName.js +25 -0
- package/dist/providers/errorHints.js +175 -0
- package/dist/providers/generic.js +331 -0
- package/dist/providers/genericChat.js +265 -0
- package/dist/providers/google.js +63 -0
- package/dist/providers/hostSystem.js +173 -0
- package/dist/providers/index.js +38 -0
- package/dist/providers/mock.js +87 -0
- package/dist/providers/modelResolution.js +42 -0
- package/dist/providers/openai.js +75 -0
- package/dist/providers/pricing.js +47 -0
- package/dist/providers/proxy.js +148 -0
- package/dist/providers/registry.js +196 -0
- package/dist/providers/types.js +1 -0
- package/dist/providers/workSystem.js +33 -0
- package/dist/storage/auth.js +65 -0
- package/dist/storage/config.js +35 -0
- package/dist/storage/keys.js +59 -0
- package/dist/storage/providers.js +337 -0
- package/dist/storage/sessions.js +150 -0
- package/dist/types.js +9 -0
- package/dist/util/debug.js +79 -0
- package/dist/util/errors.js +157 -0
- package/dist/util/prompt.js +111 -0
- package/dist/util/secrets.js +110 -0
- package/dist/util/text.js +53 -0
- package/dist/util/time.js +25 -0
- package/dist/verify/runner.js +437 -0
- package/package.json +69 -0
- package/specs/all-mode.yaml +44 -0
- package/specs/behavior/auto-fallback.yaml +49 -0
- package/specs/behavior/bare-name-routing.yaml +223 -0
- package/specs/behavior/bare-paste-confirm.yaml +125 -0
- package/specs/behavior/env-var-respected.yaml +108 -0
- package/specs/behavior/error-fidelity.yaml +92 -0
- package/specs/behavior/error-hints.yaml +160 -0
- package/specs/behavior/fresh-vs-resume.yaml +94 -0
- package/specs/behavior/fuzzy-match.yaml +208 -0
- package/specs/behavior/host-self-knowledge-fresh.yaml +66 -0
- package/specs/behavior/intent-no-mismatch.yaml +115 -0
- package/specs/behavior/login-logout.yaml +97 -0
- package/specs/behavior/no-model-allowlist.yaml +80 -0
- package/specs/behavior/paste-key.yaml +342 -0
- package/specs/behavior/provider-switching.yaml +186 -0
- package/specs/behavior/providers-json-respected.yaml +106 -0
- package/specs/behavior/self-knowledge.yaml +119 -0
- package/specs/behavior/stress-session.yaml +226 -0
- package/specs/behavior/switch-back-when-failing.yaml +90 -0
- package/specs/behavior/work-character.yaml +109 -0
- package/specs/chat-meta.yaml +349 -0
- package/specs/chat-startup.yaml +148 -0
- package/specs/chat.yaml +91 -0
- package/specs/config.yaml +42 -0
- package/specs/install.yaml +112 -0
- package/specs/keys.yaml +81 -0
- package/specs/one-shot.yaml +65 -0
- package/specs/pipe-and-files.yaml +40 -0
- package/specs/providers.yaml +172 -0
- package/specs/sessions.yaml +115 -0
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useRef } from 'react';
|
|
3
|
+
import { render, Box, Text, useApp, useInput, Static } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { streamProviderChat } from '../providers/genericChat.js';
|
|
7
|
+
import { getProviderClient } from '../providers/index.js';
|
|
8
|
+
import { resolveConfigured, resolveProviderHint, strictResolveProviderHint, configuredProviderIds, saveKeyPreservingEntry, fuzzyResolveProviderHint, } from '../storage/providers.js';
|
|
9
|
+
import { buildHostSystem, readHostContext } from '../providers/hostSystem.js';
|
|
10
|
+
import { buildWorkSystem } from '../providers/workSystem.js';
|
|
11
|
+
import { workerNameFor } from '../providers/displayName.js';
|
|
12
|
+
import { classifyError } from '../util/errors.js';
|
|
13
|
+
import { formatCost } from '../providers/pricing.js';
|
|
14
|
+
import { createSession, getMostRecentSession, loadSession, saveSession, clearSessionHistory, generateTitle, } from '../storage/sessions.js';
|
|
15
|
+
import { humanTimeAgo } from '../util/time.js';
|
|
16
|
+
import { parseProviderRoute, parseHostBack, parseBareProviderHint, isCompareCommand, parseCompareWithPrompt, parsePasteKeyIntent, isPasteConfirmAffirmative, isAffirmative, isNegative, fallbackDecision, } from './intentRouting.js';
|
|
17
|
+
import { findApiKey, sanitizeKeys, maskApiKey } from '../util/secrets.js';
|
|
18
|
+
import { explainError } from '../providers/errorHints.js';
|
|
19
|
+
// Host (planning side) is always mod8 = Anthropic Sonnet. Don't generalize
|
|
20
|
+
// the host — that's the brand. Only the work side is provider-pluggable.
|
|
21
|
+
const HOST_PROVIDER_ID = 'anthropic';
|
|
22
|
+
const HOST_MODEL = process.env.MOD8_HOST_MODEL ?? 'claude-sonnet-4-6';
|
|
23
|
+
const DEFAULT_WORK_PROVIDER_ID = 'anthropic';
|
|
24
|
+
const DEFAULT_WORK_MODEL = process.env.MOD8_WORK_MODEL ?? 'claude-opus-4-7';
|
|
25
|
+
const HOST_COLOR = '#6EE7B7'; // mint — the mod8 brand color (distinct from any provider)
|
|
26
|
+
const HOST_ICON = '✻';
|
|
27
|
+
const WORK_ICON = '◆';
|
|
28
|
+
const HOST_SPEAKER = {
|
|
29
|
+
icon: HOST_ICON,
|
|
30
|
+
name: 'mod8',
|
|
31
|
+
color: HOST_COLOR,
|
|
32
|
+
verb: 'thinking',
|
|
33
|
+
};
|
|
34
|
+
function workSpeakerFromEntry(id, entry) {
|
|
35
|
+
if (!entry) {
|
|
36
|
+
return { icon: WORK_ICON, name: id, color: '#A78BFA', verb: 'working' };
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
icon: WORK_ICON,
|
|
40
|
+
name: workerNameFor(id, entry.name),
|
|
41
|
+
color: entry.color,
|
|
42
|
+
verb: 'working',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
let nextId = 0;
|
|
46
|
+
function isAbortError(err) {
|
|
47
|
+
if (!err || typeof err !== 'object')
|
|
48
|
+
return false;
|
|
49
|
+
const e = err;
|
|
50
|
+
return (e.name === 'AbortError' ||
|
|
51
|
+
e.name === 'APIUserAbortError' ||
|
|
52
|
+
/abort/i.test(e.message ?? ''));
|
|
53
|
+
}
|
|
54
|
+
const SWITCH_TOKENS = ['<SWITCH_TO_WORK>', '<SWITCH_TO_HOST>'];
|
|
55
|
+
function stripSwitchTokens(text) {
|
|
56
|
+
let cleaned = text.replace(/<SWITCH_TO_(WORK|HOST)>/gi, '');
|
|
57
|
+
for (let i = Math.min(cleaned.length, 16); i > 0; i--) {
|
|
58
|
+
const tail = cleaned.slice(-i);
|
|
59
|
+
if (SWITCH_TOKENS.some((t) => t.startsWith(tail))) {
|
|
60
|
+
cleaned = cleaned.slice(0, -i);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return cleaned.trimEnd();
|
|
65
|
+
}
|
|
66
|
+
function detectSwitch(text, currentMode) {
|
|
67
|
+
if (currentMode === 'host' && /<SWITCH_TO_WORK>/i.test(text))
|
|
68
|
+
return 'work';
|
|
69
|
+
if (currentMode === 'work' && /<SWITCH_TO_HOST>/i.test(text))
|
|
70
|
+
return 'host';
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function Welcome() {
|
|
74
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { children: _jsx(Text, { color: HOST_COLOR, bold: true, children: `${HOST_ICON} mod8` }) }), _jsx(Text, { dimColor: true, children: ` switch to claude: ask naturally — "go", "let's work", "let me talk to claude"` }), _jsx(Text, { dimColor: true, children: ` use any provider: "use deepseek", "ask grok", "switch to mistral"` }), _jsx(Text, { dimColor: true, children: ` side-by-side: "compare all" or /compare` }), _jsx(Text, { dimColor: true, children: ` list providers: /providers · back to mod8: /mod8 or @mod8` }), _jsx(Text, { dimColor: true, children: ` clear history: /clear · quit: /exit · cancel: esc` })] }));
|
|
75
|
+
}
|
|
76
|
+
function SpeakerBlock({ speaker, body, stats, }) {
|
|
77
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: speaker.color, bold: true, children: `${speaker.icon} ${speaker.name}` }) }), _jsxs(Box, { borderStyle: "single", borderTop: false, borderRight: false, borderBottom: false, borderColor: speaker.color, paddingLeft: 1, flexDirection: "column", children: [_jsx(Text, { color: speaker.color, children: body }), stats && (_jsx(Text, { color: speaker.color, dimColor: true, children: `${(stats.inputTokens + stats.outputTokens).toLocaleString()} tok · ${(stats.latencyMs / 1000).toFixed(2)}s · ${formatCost(stats.costUsd)}` }))] })] }));
|
|
78
|
+
}
|
|
79
|
+
function MessageView({ item }) {
|
|
80
|
+
if (item.kind === 'user') {
|
|
81
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: item.speaker.color, children: '› ' }), _jsx(Text, { children: item.text })] }));
|
|
82
|
+
}
|
|
83
|
+
if (item.kind === 'assistant') {
|
|
84
|
+
const body = item.text + (item.aborted ? ' …interrupted' : '');
|
|
85
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(SpeakerBlock, { speaker: item.speaker, body: body, stats: item.stats }) }));
|
|
86
|
+
}
|
|
87
|
+
if (item.kind === 'mode-switch') {
|
|
88
|
+
const rule = '─'.repeat(60);
|
|
89
|
+
return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [_jsx(Text, { color: item.speaker.color, bold: true, children: rule }), _jsx(Text, { color: item.speaker.color, bold: true, children: ` ${item.speaker.icon} → switching to ${item.speaker.name} (${item.subtitle})` }), _jsx(Text, { color: item.speaker.color, bold: true, children: rule })] }));
|
|
90
|
+
}
|
|
91
|
+
if (item.kind === 'compare') {
|
|
92
|
+
return (_jsx(Box, { marginTop: 1, flexDirection: "column", children: item.results.map((r) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: r.color, bold: true, children: `${WORK_ICON} ${r.name}` }), r.ok ? null : _jsx(Text, { color: "red", children: ` ✗ ${r.error}` })] }), r.ok && (_jsxs(Box, { borderStyle: "single", borderTop: false, borderRight: false, borderBottom: false, borderColor: r.color, paddingLeft: 1, flexDirection: "column", children: [_jsx(Text, { color: r.color, children: r.text ?? '' }), r.stats && (_jsx(Text, { color: r.color, dimColor: true, children: `${(r.stats.inputTokens + r.stats.outputTokens).toLocaleString()} tok · ${(r.stats.latencyMs / 1000).toFixed(2)}s · ${formatCost(r.stats.costUsd)}` }))] }))] }, r.id))) }));
|
|
93
|
+
}
|
|
94
|
+
if (item.kind === 'error') {
|
|
95
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", children: `mod8: ${item.text}` }) }));
|
|
96
|
+
}
|
|
97
|
+
if (item.kind === 'info') {
|
|
98
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: item.text }) }));
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
function ThinkingIndicator({ speaker, startedAt }) {
|
|
103
|
+
const [, setTick] = useState(0);
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const interval = setInterval(() => setTick((t) => t + 1), 250);
|
|
106
|
+
return () => clearInterval(interval);
|
|
107
|
+
}, []);
|
|
108
|
+
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
109
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: speaker.color, children: `${speaker.icon} ` }), _jsx(Text, { color: speaker.color, bold: true, children: speaker.name }), _jsx(Text, { dimColor: true, children: ` ${speaker.verb}… (${elapsed}s)` })] }));
|
|
110
|
+
}
|
|
111
|
+
function InputBox({ speaker, value, onChange, onSubmit, disabled, }) {
|
|
112
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: disabled ? 'gray' : speaker.color, paddingX: 1, children: [_jsx(Text, { color: speaker.color, children: '› ' }), _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit, focus: !disabled })] }));
|
|
113
|
+
}
|
|
114
|
+
function StatusLine({ speaker, errorTag }) {
|
|
115
|
+
return (_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: speaker.color, bold: true, children: `${speaker.icon} ${speaker.name}` }), errorTag && _jsx(Text, { color: "red", children: ` (${errorTag})` }), _jsx(Text, { dimColor: true, children: ` · esc to interrupt · /exit to quit` })] }));
|
|
116
|
+
}
|
|
117
|
+
function buildTranscript(messages) {
|
|
118
|
+
const items = [];
|
|
119
|
+
let prev = null;
|
|
120
|
+
for (const m of messages) {
|
|
121
|
+
// Best-effort speaker reconstruction — we don't store provider id per
|
|
122
|
+
// message yet, so resumed work-mode turns show the default speaker.
|
|
123
|
+
const speaker = m.mode === 'host'
|
|
124
|
+
? HOST_SPEAKER
|
|
125
|
+
: workSpeakerFromEntry(DEFAULT_WORK_PROVIDER_ID, undefined);
|
|
126
|
+
if (prev !== null && m.mode !== prev) {
|
|
127
|
+
items.push({
|
|
128
|
+
kind: 'mode-switch',
|
|
129
|
+
id: nextId++,
|
|
130
|
+
targetMode: m.mode,
|
|
131
|
+
speaker,
|
|
132
|
+
subtitle: m.mode === 'host' ? 'host mode' : 'work mode',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (m.role === 'user') {
|
|
136
|
+
items.push({ kind: 'user', id: nextId++, text: m.content, mode: m.mode, speaker });
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
items.push({
|
|
140
|
+
kind: 'assistant',
|
|
141
|
+
id: nextId++,
|
|
142
|
+
text: m.content,
|
|
143
|
+
mode: m.mode,
|
|
144
|
+
speaker,
|
|
145
|
+
stats: m.stats,
|
|
146
|
+
aborted: m.aborted,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
prev = m.mode;
|
|
150
|
+
}
|
|
151
|
+
return items;
|
|
152
|
+
}
|
|
153
|
+
function App({ session: initialSession, }) {
|
|
154
|
+
const { exit } = useApp();
|
|
155
|
+
const sessionRef = useRef(initialSession);
|
|
156
|
+
const [transcript, setTranscript] = useState(() => buildTranscript(initialSession.messages));
|
|
157
|
+
const [mode, setMode] = useState('host');
|
|
158
|
+
const [workProviderId, setWorkProviderId] = useState(DEFAULT_WORK_PROVIDER_ID);
|
|
159
|
+
const [workEntry, setWorkEntry] = useState(undefined);
|
|
160
|
+
const [input, setInput] = useState('');
|
|
161
|
+
const [streaming, setStreaming] = useState(false);
|
|
162
|
+
const [streamedText, setStreamedText] = useState('');
|
|
163
|
+
const [streamStart, setStreamStart] = useState(0);
|
|
164
|
+
// When the last work-mode call errored, this holds the classified error
|
|
165
|
+
// ("rate limited", "invalid API key", …) so the status line can show it.
|
|
166
|
+
// Cleared on the next successful turn or on switch-back to host.
|
|
167
|
+
const [lastWorkError, setLastWorkError] = useState(null);
|
|
168
|
+
const aborterRef = useRef(null);
|
|
169
|
+
const modeRef = useRef('host');
|
|
170
|
+
const workIdRef = useRef(DEFAULT_WORK_PROVIDER_ID);
|
|
171
|
+
const workEntryRef = useRef(undefined);
|
|
172
|
+
const titleGenerationStartedRef = useRef(false);
|
|
173
|
+
// Consecutive work-mode error count. Resets on success or on switch-back.
|
|
174
|
+
// After AUTO_FALLBACK_THRESHOLD, the chat auto-switches to host mode.
|
|
175
|
+
const consecutiveWorkErrorsRef = useRef(0);
|
|
176
|
+
// Inline paste-key flow: when the user says "add a key" / "let me add
|
|
177
|
+
// gemini" / etc., we set this to true and the NEXT user message is treated
|
|
178
|
+
// as a key paste (or rejected if it doesn't match a known key shape).
|
|
179
|
+
// One-shot — cleared after the next message either way.
|
|
180
|
+
const awaitingKeyRef = useRef(false);
|
|
181
|
+
// Bare-paste auto-detect: when the user pastes a key WITHOUT saying "add
|
|
182
|
+
// a key" first, we cache the raw key in memory (NEVER on disk) and ask
|
|
183
|
+
// "save this as <provider>?". The next message either confirms (save)
|
|
184
|
+
// or anything else (cancel + fall through to normal dispatch).
|
|
185
|
+
const pendingKeyRef = useRef(null);
|
|
186
|
+
// Fuzzy-match confirm: when "gimini" is 2 edits from "gemini" and the
|
|
187
|
+
// bare term is short, we ask "did you mean google?" instead of routing
|
|
188
|
+
// automatically. Yes/affirmative → switch; anything else → cancel + fall
|
|
189
|
+
// through to normal dispatch.
|
|
190
|
+
const pendingFuzzyRef = useRef(null);
|
|
191
|
+
// Keep the work entry in sync whenever workProviderId changes.
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
workIdRef.current = workProviderId;
|
|
194
|
+
let cancelled = false;
|
|
195
|
+
void resolveConfigured(workProviderId).then((entry) => {
|
|
196
|
+
if (cancelled)
|
|
197
|
+
return;
|
|
198
|
+
workEntryRef.current = entry;
|
|
199
|
+
setWorkEntry(entry);
|
|
200
|
+
});
|
|
201
|
+
return () => {
|
|
202
|
+
cancelled = true;
|
|
203
|
+
};
|
|
204
|
+
}, [workProviderId]);
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
modeRef.current = mode;
|
|
207
|
+
}, [mode]);
|
|
208
|
+
const persist = () => {
|
|
209
|
+
sessionRef.current.lastActivity = Date.now();
|
|
210
|
+
saveSession(sessionRef.current).catch(() => { });
|
|
211
|
+
};
|
|
212
|
+
// Reset work-mode provider to the default (anthropic / claude) whenever
|
|
213
|
+
// we transition into host mode. This guarantees that the next
|
|
214
|
+
// <SWITCH_TO_WORK> emitted by the host lands on claude — matching what
|
|
215
|
+
// the host's system prompt promises ("handing you off to claude").
|
|
216
|
+
// If the user wants the previous work provider back, they can re-route
|
|
217
|
+
// explicitly: "use codex", "talk to grok", "deepseek", etc.
|
|
218
|
+
const resetWorkToDefault = async () => {
|
|
219
|
+
if (workIdRef.current === DEFAULT_WORK_PROVIDER_ID)
|
|
220
|
+
return;
|
|
221
|
+
setWorkProviderId(DEFAULT_WORK_PROVIDER_ID);
|
|
222
|
+
workIdRef.current = DEFAULT_WORK_PROVIDER_ID;
|
|
223
|
+
const defaultEntry = await resolveConfigured(DEFAULT_WORK_PROVIDER_ID);
|
|
224
|
+
workEntryRef.current = defaultEntry;
|
|
225
|
+
setWorkEntry(defaultEntry);
|
|
226
|
+
};
|
|
227
|
+
const maybeGenerateTitle = () => {
|
|
228
|
+
const session = sessionRef.current;
|
|
229
|
+
if (titleGenerationStartedRef.current)
|
|
230
|
+
return;
|
|
231
|
+
if (session.title)
|
|
232
|
+
return;
|
|
233
|
+
const assistantTurns = session.messages.filter((m) => m.role === 'assistant').length;
|
|
234
|
+
if (assistantTurns < 2)
|
|
235
|
+
return;
|
|
236
|
+
titleGenerationStartedRef.current = true;
|
|
237
|
+
void (async () => {
|
|
238
|
+
const title = await generateTitle(session.messages);
|
|
239
|
+
if (title) {
|
|
240
|
+
session.title = title;
|
|
241
|
+
await saveSession(session).catch(() => { });
|
|
242
|
+
}
|
|
243
|
+
})();
|
|
244
|
+
};
|
|
245
|
+
useInput((_input, key) => {
|
|
246
|
+
if (key.escape && aborterRef.current) {
|
|
247
|
+
aborterRef.current.abort();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
const append = (item) => {
|
|
251
|
+
setTranscript((prev) => [...prev, { ...item, id: nextId++ }]);
|
|
252
|
+
};
|
|
253
|
+
const speakerForMode = (m, overrideId, overrideEntry) => {
|
|
254
|
+
if (m === 'host')
|
|
255
|
+
return HOST_SPEAKER;
|
|
256
|
+
return workSpeakerFromEntry(overrideId ?? workIdRef.current, overrideEntry ?? workEntryRef.current);
|
|
257
|
+
};
|
|
258
|
+
const switchToWorkProvider = async (rawHint, label) => {
|
|
259
|
+
// Map the user's hint (id, display name, or synonym like "gpt") to a
|
|
260
|
+
// canonical provider id before checking whether it's configured.
|
|
261
|
+
const resolvedId = await resolveProviderHint(rawHint);
|
|
262
|
+
if (!resolvedId) {
|
|
263
|
+
append({
|
|
264
|
+
kind: 'error',
|
|
265
|
+
text: `unknown provider "${rawHint}". Try: /providers, or mod8 add-provider.`,
|
|
266
|
+
});
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
const entry = await resolveConfigured(resolvedId);
|
|
270
|
+
if (!entry) {
|
|
271
|
+
append({
|
|
272
|
+
kind: 'error',
|
|
273
|
+
text: `${resolvedId} not configured. Run: mod8 keys set ${resolvedId} (or mod8 add-provider).`,
|
|
274
|
+
});
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
setWorkProviderId(resolvedId);
|
|
278
|
+
workIdRef.current = resolvedId;
|
|
279
|
+
workEntryRef.current = entry;
|
|
280
|
+
setWorkEntry(entry);
|
|
281
|
+
const speaker = workSpeakerFromEntry(resolvedId, entry);
|
|
282
|
+
// Banner: if the user's hint matches the speaker name we're showing, it's
|
|
283
|
+
// just "<name> mode". Otherwise show the mapping so they know what just
|
|
284
|
+
// happened (e.g. "codex mode (gpt → codex)").
|
|
285
|
+
const hintLower = rawHint.toLowerCase();
|
|
286
|
+
const speakerLower = speaker.name.toLowerCase();
|
|
287
|
+
const subtitle = hintLower === speakerLower || hintLower === resolvedId.toLowerCase()
|
|
288
|
+
? `${speaker.name} mode`
|
|
289
|
+
: `${speaker.name} mode (${rawHint} → ${speaker.name})`;
|
|
290
|
+
append({ kind: 'mode-switch', targetMode: 'work', speaker, subtitle });
|
|
291
|
+
setMode('work');
|
|
292
|
+
modeRef.current = 'work';
|
|
293
|
+
return true;
|
|
294
|
+
};
|
|
295
|
+
// Save a detected key to providers.json and append a confirmation banner.
|
|
296
|
+
// Used by both the explicit awaitingKey flow ("add a key" + paste) and the
|
|
297
|
+
// bare-paste confirm flow (paste + "yes"). Preserves any existing
|
|
298
|
+
// entry's customizations (defaultModel, custom name, etc.) so a key
|
|
299
|
+
// update doesn't clobber the user's model choice.
|
|
300
|
+
const persistKey = async (key, template) => {
|
|
301
|
+
await saveKeyPreservingEntry(key, template);
|
|
302
|
+
append({
|
|
303
|
+
kind: 'info',
|
|
304
|
+
text: `✓ saved ${template.name} (${template.id}) — key ${maskApiKey(key)}. use it: "use ${template.id}" or just "${template.id}".`,
|
|
305
|
+
});
|
|
306
|
+
};
|
|
307
|
+
// Try fuzzy matching for a hint that didn't resolve exactly. Returns
|
|
308
|
+
// 'routed' when we either switched, asked for confirmation, or surfaced an
|
|
309
|
+
// ambiguous-match message — in all three cases the caller should NOT fall
|
|
310
|
+
// through to the LLM. Returns 'noop' when no candidate is within fuzzy
|
|
311
|
+
// range (so e.g. "what's up" continues to the LLM).
|
|
312
|
+
const tryFuzzyRoute = async (name, rest) => {
|
|
313
|
+
// Skip fuzzy for very short inputs and common affirmative/negative
|
|
314
|
+
// tokens. Without these guards, "go" / "ok" / "xyz" trigger
|
|
315
|
+
// false-positive matches against built-in provider ids — too noisy.
|
|
316
|
+
if (name.length < 4)
|
|
317
|
+
return 'noop';
|
|
318
|
+
if (isAffirmative(name) || isNegative(name))
|
|
319
|
+
return 'noop';
|
|
320
|
+
const fuzzy = await fuzzyResolveProviderHint(name);
|
|
321
|
+
if (fuzzy.length === 0)
|
|
322
|
+
return 'noop';
|
|
323
|
+
if (fuzzy.length > 1) {
|
|
324
|
+
const ids = fuzzy.map((c) => c.id).join(', ');
|
|
325
|
+
append({
|
|
326
|
+
kind: 'info',
|
|
327
|
+
text: `multiple close matches: ${ids}. type one to switch.`,
|
|
328
|
+
});
|
|
329
|
+
return 'routed';
|
|
330
|
+
}
|
|
331
|
+
const m = fuzzy[0];
|
|
332
|
+
// Distance-2 typos on short inputs (≤4 chars) ask first to dodge
|
|
333
|
+
// false-positive frustration on common typos like "go" / "ok" / "no".
|
|
334
|
+
const askFirst = m.distance === 2 && name.length <= 4;
|
|
335
|
+
if (askFirst) {
|
|
336
|
+
pendingFuzzyRef.current = { id: m.id, rest };
|
|
337
|
+
append({
|
|
338
|
+
kind: 'info',
|
|
339
|
+
text: `did you mean "${m.id}"? (yes / no)`,
|
|
340
|
+
});
|
|
341
|
+
return 'routed';
|
|
342
|
+
}
|
|
343
|
+
const entry = await resolveConfigured(m.id);
|
|
344
|
+
if (!entry) {
|
|
345
|
+
append({
|
|
346
|
+
kind: 'info',
|
|
347
|
+
text: `did you mean "${m.id}"? — not configured yet (run mod8 keys set ${m.id}).`,
|
|
348
|
+
});
|
|
349
|
+
return 'routed';
|
|
350
|
+
}
|
|
351
|
+
append({
|
|
352
|
+
kind: 'info',
|
|
353
|
+
text: `routing to ${m.id} — did you mean that?`,
|
|
354
|
+
});
|
|
355
|
+
const ok = await switchToWorkProvider(m.id, `${m.id} mode`);
|
|
356
|
+
if (!ok)
|
|
357
|
+
return 'noop';
|
|
358
|
+
if (rest)
|
|
359
|
+
await sendMessage(rest, 'work');
|
|
360
|
+
return 'routed';
|
|
361
|
+
};
|
|
362
|
+
const handleSubmit = async (rawValue) => {
|
|
363
|
+
const raw = rawValue.trim();
|
|
364
|
+
if (!raw)
|
|
365
|
+
return;
|
|
366
|
+
setInput('');
|
|
367
|
+
// === STATE HANDLERS (run on RAW; key detection requires it) ===========
|
|
368
|
+
// 1. Awaiting explicit paste-key — armed after "add a key" / "let me add
|
|
369
|
+
// gemini" / etc. Next message: detect+save the key.
|
|
370
|
+
if (awaitingKeyRef.current) {
|
|
371
|
+
awaitingKeyRef.current = false;
|
|
372
|
+
if (parseHostBack(raw)) {
|
|
373
|
+
// fall through to host-back dispatch below
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
const found = findApiKey(raw);
|
|
377
|
+
if (found) {
|
|
378
|
+
await persistKey(found.key, found.template);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (isNegative(raw)) {
|
|
382
|
+
append({ kind: 'info', text: 'cancelled — no key saved.' });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
append({
|
|
386
|
+
kind: 'info',
|
|
387
|
+
text: "that doesn't look like a known key format. want to try again, " +
|
|
388
|
+
'or use `mod8 add-provider` for a custom provider?',
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// 2. Pending bare-paste confirm — armed when the user pasted a key
|
|
394
|
+
// without the "add a key" preamble. Confirm → save; negative →
|
|
395
|
+
// discard; anything else → discard + fall through (don't trap).
|
|
396
|
+
if (pendingKeyRef.current) {
|
|
397
|
+
const cached = pendingKeyRef.current;
|
|
398
|
+
pendingKeyRef.current = null;
|
|
399
|
+
if (isPasteConfirmAffirmative(raw)) {
|
|
400
|
+
await persistKey(cached.rawKey, cached.template);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (isNegative(raw)) {
|
|
404
|
+
append({ kind: 'info', text: 'cancelled — discarded the key.' });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// Any other input (including parseHostBack matches) cancels the paste
|
|
408
|
+
// silently and falls through to the regular dispatch. No trap.
|
|
409
|
+
append({ kind: 'info', text: 'cancelled — discarded the key.' });
|
|
410
|
+
}
|
|
411
|
+
// 3. Pending fuzzy confirm — "did you mean google?" follow-up.
|
|
412
|
+
if (pendingFuzzyRef.current) {
|
|
413
|
+
const cached = pendingFuzzyRef.current;
|
|
414
|
+
pendingFuzzyRef.current = null;
|
|
415
|
+
if (isAffirmative(raw)) {
|
|
416
|
+
const ok = await switchToWorkProvider(cached.id, `${cached.id} mode`);
|
|
417
|
+
if (ok && cached.rest)
|
|
418
|
+
await sendMessage(cached.rest, 'work');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (isNegative(raw)) {
|
|
422
|
+
append({ kind: 'info', text: 'cancelled.' });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
// Any other input cancels and falls through.
|
|
426
|
+
}
|
|
427
|
+
// === BARE-PASTE AUTO-DETECT (host mode, raw input) ====================
|
|
428
|
+
// If the user pastes a recognizable key without first saying "add a key",
|
|
429
|
+
// surface it as a save prompt instead of letting the host LLM lecture
|
|
430
|
+
// about `mod8 keys set <id>`. Host mode only — work mode shouldn't
|
|
431
|
+
// intercept turns the user might intend for the worker.
|
|
432
|
+
if (modeRef.current === 'host') {
|
|
433
|
+
const found = findApiKey(raw);
|
|
434
|
+
if (found) {
|
|
435
|
+
pendingKeyRef.current = { rawKey: found.key, template: found.template };
|
|
436
|
+
append({
|
|
437
|
+
kind: 'info',
|
|
438
|
+
text: `I see ${found.template.name} key ${maskApiKey(found.key)}. Save it as \`${found.template.id}\`? (yes / no)`,
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Sanitize from here on so an API key in the input never reaches the
|
|
444
|
+
// transcript / session JSON / LLM context. Idempotent — double-applying
|
|
445
|
+
// is a no-op.
|
|
446
|
+
const value = sanitizeKeys(raw);
|
|
447
|
+
if (value === '/exit' || value === '/quit') {
|
|
448
|
+
exit();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (value === '/clear') {
|
|
452
|
+
await clearSessionHistory(sessionRef.current);
|
|
453
|
+
titleGenerationStartedRef.current = false;
|
|
454
|
+
setTranscript([]);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (value === '/providers') {
|
|
458
|
+
await listProvidersInChat(append);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (isCompareCommand(value)) {
|
|
462
|
+
append({
|
|
463
|
+
kind: 'info',
|
|
464
|
+
text: '— compare needs a follow-up prompt. usage: "compare all: <prompt>"',
|
|
465
|
+
});
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const comparePayload = parseCompareWithPrompt(value);
|
|
469
|
+
if (comparePayload) {
|
|
470
|
+
await runCompareTurn(comparePayload);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
// Switch-back to host runs BEFORE provider routing AND before any LLM
|
|
474
|
+
// call, so users are never trapped when the current work-mode provider
|
|
475
|
+
// is failing. In work mode it's the escape hatch; in host mode it's a
|
|
476
|
+
// graceful no-op (user is already there). Either way, parseHostBack
|
|
477
|
+
// input never reaches the work provider.
|
|
478
|
+
const currentMode = modeRef.current;
|
|
479
|
+
const back = parseHostBack(value);
|
|
480
|
+
if (back) {
|
|
481
|
+
if (currentMode === 'work') {
|
|
482
|
+
append({
|
|
483
|
+
kind: 'mode-switch',
|
|
484
|
+
targetMode: 'host',
|
|
485
|
+
speaker: HOST_SPEAKER,
|
|
486
|
+
subtitle: 'host mode',
|
|
487
|
+
});
|
|
488
|
+
setMode('host');
|
|
489
|
+
modeRef.current = 'host';
|
|
490
|
+
consecutiveWorkErrorsRef.current = 0;
|
|
491
|
+
setLastWorkError(null);
|
|
492
|
+
await resetWorkToDefault();
|
|
493
|
+
if (back.rest)
|
|
494
|
+
await sendMessage(back.rest, 'host');
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// Already in host — no-op with info, but still forward any inline rest
|
|
498
|
+
// ("mod8 thanks for the help" → send "thanks for the help" to host).
|
|
499
|
+
if (back.rest) {
|
|
500
|
+
await sendMessage(back.rest, 'host');
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
append({ kind: 'info', text: "you're already in mod8 host." });
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
// Inline paste-key flow. "add a key" / "let me add gemini" / "i wanna
|
|
508
|
+
// paste my anthropic key" → consent line + arm awaitingKey for the next
|
|
509
|
+
// turn. Runs in host mode only — work mode is for the worker, not for
|
|
510
|
+
// mod8 config (and the user should mod8-back first anyway).
|
|
511
|
+
if (currentMode === 'host') {
|
|
512
|
+
const paste = parsePasteKeyIntent(value);
|
|
513
|
+
if (paste) {
|
|
514
|
+
let resolvedHint = null;
|
|
515
|
+
if (paste.providerHint) {
|
|
516
|
+
resolvedHint = await resolveProviderHint(paste.providerHint);
|
|
517
|
+
}
|
|
518
|
+
const triggerConsent = !paste.providerHint || resolvedHint;
|
|
519
|
+
if (triggerConsent) {
|
|
520
|
+
awaitingKeyRef.current = true;
|
|
521
|
+
const target = resolvedHint ? ` (${resolvedHint})` : '';
|
|
522
|
+
// Use a fake-but-realistic sample so the user sees the actual mask
|
|
523
|
+
// shape they'll get, not a hand-written approximation.
|
|
524
|
+
const sample = maskApiKey('sk-ant-EXAMPLEFAKEKEYNOTREAL00000');
|
|
525
|
+
append({
|
|
526
|
+
kind: 'info',
|
|
527
|
+
text: `Sure — paste your API key${target} in your next message. ` +
|
|
528
|
+
`It's safe: the key is saved locally and masked in this chat ` +
|
|
529
|
+
`(you'll see it as ${sample}, not the full key). Nobody else sees it.`,
|
|
530
|
+
});
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// Provider routing: "use deepseek" / "ask grok" / "switch to mistral"
|
|
536
|
+
const route = parseProviderRoute(value);
|
|
537
|
+
if (route) {
|
|
538
|
+
// Try exact resolution first; if unknown, fuzzy-match before erroring.
|
|
539
|
+
// "use gimini" → "did you mean google?" instead of "unknown provider".
|
|
540
|
+
const exact = await resolveProviderHint(route.id);
|
|
541
|
+
if (!exact) {
|
|
542
|
+
const fuzzed = await tryFuzzyRoute(route.id, route.rest);
|
|
543
|
+
if (fuzzed === 'routed')
|
|
544
|
+
return;
|
|
545
|
+
// Zero fuzzy candidates — surface the original error.
|
|
546
|
+
append({
|
|
547
|
+
kind: 'error',
|
|
548
|
+
text: `unknown provider "${route.id}". Try: /providers, or mod8 add-provider.`,
|
|
549
|
+
});
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const ok = await switchToWorkProvider(route.id, `${route.id} mode`);
|
|
553
|
+
if (!ok)
|
|
554
|
+
return;
|
|
555
|
+
if (route.rest)
|
|
556
|
+
await sendMessage(route.rest, 'work');
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
// Bare-name / first-word / greeting: catch "codex", "hi codex",
|
|
560
|
+
// "codex tell me a joke" — anything where the user names a configured
|
|
561
|
+
// provider without a "use/ask/talk" verb. Strict resolution prevents
|
|
562
|
+
// false positives ("haiku", "claude alone is great" don't route).
|
|
563
|
+
const bare = parseBareProviderHint(value);
|
|
564
|
+
if (bare) {
|
|
565
|
+
const resolved = bare.resolution === 'strict'
|
|
566
|
+
? await strictResolveProviderHint(bare.name)
|
|
567
|
+
: await resolveProviderHint(bare.name);
|
|
568
|
+
if (resolved) {
|
|
569
|
+
const ok = await switchToWorkProvider(bare.name, `${bare.name} mode`);
|
|
570
|
+
if (!ok)
|
|
571
|
+
return;
|
|
572
|
+
if (bare.rest)
|
|
573
|
+
await sendMessage(bare.rest, 'work');
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
// Exact lookup failed — try fuzzy. If even fuzzy returns nothing, we
|
|
577
|
+
// fall through to the LLM (the bare name was probably normal English).
|
|
578
|
+
const fuzzed = await tryFuzzyRoute(bare.name, bare.rest);
|
|
579
|
+
if (fuzzed === 'routed')
|
|
580
|
+
return;
|
|
581
|
+
// Not a real provider, not a near-typo — fall through to LLM.
|
|
582
|
+
}
|
|
583
|
+
await sendMessage(value, currentMode);
|
|
584
|
+
};
|
|
585
|
+
const sendMessage = async (text, currentMode) => {
|
|
586
|
+
const userSpeaker = speakerForMode(currentMode);
|
|
587
|
+
sessionRef.current.messages.push({ role: 'user', content: text, mode: currentMode });
|
|
588
|
+
append({ kind: 'user', text, mode: currentMode, speaker: userSpeaker });
|
|
589
|
+
persist();
|
|
590
|
+
setStreaming(true);
|
|
591
|
+
setStreamedText('');
|
|
592
|
+
setStreamStart(Date.now());
|
|
593
|
+
const ctrl = new AbortController();
|
|
594
|
+
aborterRef.current = ctrl;
|
|
595
|
+
let collected = '';
|
|
596
|
+
let usage;
|
|
597
|
+
let aborted = false;
|
|
598
|
+
let errored = false;
|
|
599
|
+
const apiMessages = sessionRef.current.messages.map((m) => ({
|
|
600
|
+
role: m.role,
|
|
601
|
+
content: m.content,
|
|
602
|
+
}));
|
|
603
|
+
const providerId = currentMode === 'host' ? HOST_PROVIDER_ID : workIdRef.current;
|
|
604
|
+
const model = currentMode === 'host'
|
|
605
|
+
? HOST_MODEL
|
|
606
|
+
: (workEntryRef.current?.defaultModel ?? DEFAULT_WORK_MODEL);
|
|
607
|
+
// Bug 1 fix: rebuild the host system prompt with FRESH provider context
|
|
608
|
+
// on every turn. When the user adds a key inline mid-session, the host
|
|
609
|
+
// can immediately answer "do I have google configured?" correctly —
|
|
610
|
+
// otherwise the prompt is frozen at startup and the host lies.
|
|
611
|
+
const system = currentMode === 'host'
|
|
612
|
+
? buildHostSystem(await readHostContext())
|
|
613
|
+
: buildWorkSystem(workSpeakerFromEntry(providerId, workEntryRef.current).name);
|
|
614
|
+
try {
|
|
615
|
+
for await (const event of streamProviderChat({
|
|
616
|
+
providerId,
|
|
617
|
+
system,
|
|
618
|
+
messages: apiMessages,
|
|
619
|
+
model,
|
|
620
|
+
signal: ctrl.signal,
|
|
621
|
+
})) {
|
|
622
|
+
if (event.type === 'text') {
|
|
623
|
+
collected += event.delta;
|
|
624
|
+
setStreamedText(stripSwitchTokens(collected));
|
|
625
|
+
}
|
|
626
|
+
else if (event.type === 'done') {
|
|
627
|
+
usage = event.usage;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
if (isAbortError(err)) {
|
|
633
|
+
aborted = true;
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
errored = true;
|
|
637
|
+
if (!collected)
|
|
638
|
+
sessionRef.current.messages.pop();
|
|
639
|
+
// Diagnose the error per-kind and surface a structured explanation:
|
|
640
|
+
// short summary (with HTTP code + quoted raw message) → long fixes,
|
|
641
|
+
// then the auto-fallback path uses the kind-aware suggestion line.
|
|
642
|
+
const explained = explainError(err, providerId);
|
|
643
|
+
append({ kind: 'error', text: explained.short });
|
|
644
|
+
if (explained.long)
|
|
645
|
+
append({ kind: 'info', text: explained.long });
|
|
646
|
+
persist();
|
|
647
|
+
// Work-mode-only: track consecutive failures, advise user, and
|
|
648
|
+
// auto-fallback to host after AUTO_FALLBACK_THRESHOLD errors so the
|
|
649
|
+
// user is never stuck unable to escape a broken provider.
|
|
650
|
+
if (currentMode === 'work') {
|
|
651
|
+
const speakerName = workSpeakerFromEntry(providerId, workEntryRef.current).name;
|
|
652
|
+
consecutiveWorkErrorsRef.current += 1;
|
|
653
|
+
setLastWorkError(explained.short);
|
|
654
|
+
const decision = fallbackDecision(consecutiveWorkErrorsRef.current);
|
|
655
|
+
if (decision === 'fallback') {
|
|
656
|
+
append({
|
|
657
|
+
kind: 'info',
|
|
658
|
+
text: `${speakerName} has been failing (${consecutiveWorkErrorsRef.current} errors in a row) — switching you back to mod8 host. ${explained.suggestion}`,
|
|
659
|
+
});
|
|
660
|
+
append({
|
|
661
|
+
kind: 'mode-switch',
|
|
662
|
+
targetMode: 'host',
|
|
663
|
+
speaker: HOST_SPEAKER,
|
|
664
|
+
subtitle: 'host mode',
|
|
665
|
+
});
|
|
666
|
+
setMode('host');
|
|
667
|
+
modeRef.current = 'host';
|
|
668
|
+
consecutiveWorkErrorsRef.current = 0;
|
|
669
|
+
setLastWorkError(null);
|
|
670
|
+
await resetWorkToDefault();
|
|
671
|
+
}
|
|
672
|
+
else if (decision === 'warn') {
|
|
673
|
+
append({
|
|
674
|
+
kind: 'info',
|
|
675
|
+
text: `tip: ${explained.suggestion}`,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
finally {
|
|
682
|
+
aborterRef.current = null;
|
|
683
|
+
}
|
|
684
|
+
if (!errored) {
|
|
685
|
+
// Successful turn — reset error tracking for work mode.
|
|
686
|
+
if (currentMode === 'work') {
|
|
687
|
+
consecutiveWorkErrorsRef.current = 0;
|
|
688
|
+
if (lastWorkError !== null)
|
|
689
|
+
setLastWorkError(null);
|
|
690
|
+
}
|
|
691
|
+
const switchTo = detectSwitch(collected, currentMode);
|
|
692
|
+
const cleaned = stripSwitchTokens(collected);
|
|
693
|
+
if (cleaned) {
|
|
694
|
+
sessionRef.current.messages.push({
|
|
695
|
+
role: 'assistant',
|
|
696
|
+
content: cleaned,
|
|
697
|
+
mode: currentMode,
|
|
698
|
+
stats: usage,
|
|
699
|
+
aborted,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
append({
|
|
703
|
+
kind: 'assistant',
|
|
704
|
+
text: cleaned,
|
|
705
|
+
mode: currentMode,
|
|
706
|
+
speaker: speakerForMode(currentMode),
|
|
707
|
+
stats: usage,
|
|
708
|
+
aborted,
|
|
709
|
+
});
|
|
710
|
+
if (switchTo && !aborted) {
|
|
711
|
+
// Resetting BEFORE deriving the target speaker matters: if the host
|
|
712
|
+
// emitted <SWITCH_TO_WORK>, the work mode must land on the default
|
|
713
|
+
// (claude), not whatever provider was last selected. That way the
|
|
714
|
+
// host's spoken text ("handing you off to claude") matches the
|
|
715
|
+
// banner ("switching to claude (work mode)") instead of contradicting
|
|
716
|
+
// it (the bug where banner showed "codex").
|
|
717
|
+
if (switchTo === 'work' && currentMode === 'host') {
|
|
718
|
+
await resetWorkToDefault();
|
|
719
|
+
}
|
|
720
|
+
const targetSpeaker = switchTo === 'host' ? HOST_SPEAKER : workSpeakerFromEntry(workIdRef.current, workEntryRef.current);
|
|
721
|
+
append({
|
|
722
|
+
kind: 'mode-switch',
|
|
723
|
+
targetMode: switchTo,
|
|
724
|
+
speaker: targetSpeaker,
|
|
725
|
+
subtitle: switchTo === 'host' ? 'host mode' : 'work mode',
|
|
726
|
+
});
|
|
727
|
+
setMode(switchTo);
|
|
728
|
+
modeRef.current = switchTo;
|
|
729
|
+
if (switchTo === 'host') {
|
|
730
|
+
await resetWorkToDefault();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
persist();
|
|
734
|
+
maybeGenerateTitle();
|
|
735
|
+
}
|
|
736
|
+
setStreaming(false);
|
|
737
|
+
setStreamedText('');
|
|
738
|
+
};
|
|
739
|
+
const runCompareTurn = async (prompt) => {
|
|
740
|
+
const ids = await configuredProviderIds();
|
|
741
|
+
// Include any legacy providers with env keys.
|
|
742
|
+
for (const legacy of ['anthropic', 'openai', 'google']) {
|
|
743
|
+
if (!ids.includes(legacy)) {
|
|
744
|
+
const env = await resolveConfigured(legacy);
|
|
745
|
+
if (env)
|
|
746
|
+
ids.push(legacy);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (ids.length === 0) {
|
|
750
|
+
append({
|
|
751
|
+
kind: 'error',
|
|
752
|
+
text: 'no providers configured — run mod8 add-provider or mod8 keys set <id>.',
|
|
753
|
+
});
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
setStreaming(true);
|
|
757
|
+
setStreamStart(Date.now());
|
|
758
|
+
const settled = await Promise.allSettled(ids.map(async (id) => {
|
|
759
|
+
const client = await getProviderClient(id);
|
|
760
|
+
return client.call(prompt);
|
|
761
|
+
}));
|
|
762
|
+
const blocks = await Promise.all(ids.map(async (id, i) => {
|
|
763
|
+
const entry = await resolveConfigured(id);
|
|
764
|
+
const speaker = workSpeakerFromEntry(id, entry);
|
|
765
|
+
const s = settled[i];
|
|
766
|
+
if (s.status === 'fulfilled') {
|
|
767
|
+
return {
|
|
768
|
+
id,
|
|
769
|
+
name: speaker.name,
|
|
770
|
+
color: speaker.color,
|
|
771
|
+
ok: true,
|
|
772
|
+
text: s.value.text.trimEnd(),
|
|
773
|
+
stats: {
|
|
774
|
+
inputTokens: s.value.inputTokens,
|
|
775
|
+
outputTokens: s.value.outputTokens,
|
|
776
|
+
latencyMs: s.value.latencyMs,
|
|
777
|
+
model: s.value.model,
|
|
778
|
+
costUsd: s.value.costUsd,
|
|
779
|
+
},
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
return {
|
|
783
|
+
id,
|
|
784
|
+
name: speaker.name,
|
|
785
|
+
color: speaker.color,
|
|
786
|
+
ok: false,
|
|
787
|
+
error: classifyError(s.reason, id),
|
|
788
|
+
};
|
|
789
|
+
}));
|
|
790
|
+
setStreaming(false);
|
|
791
|
+
append({ kind: 'compare', results: blocks });
|
|
792
|
+
};
|
|
793
|
+
const staticItems = [
|
|
794
|
+
{ kind: 'welcome', id: 'welcome' },
|
|
795
|
+
...transcript,
|
|
796
|
+
];
|
|
797
|
+
const inputSpeaker = mode === 'host' ? HOST_SPEAKER : workSpeakerFromEntry(workProviderId, workEntry);
|
|
798
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item) => item.kind === 'welcome' ? (_jsx(Welcome, {}, "welcome")) : (_jsx(MessageView, { item: item }, item.id)) }), streaming && streamedText.length > 0 && (_jsx(Box, { marginTop: 1, children: _jsx(SpeakerBlock, { speaker: inputSpeaker, body: streamedText }) })), streaming && _jsx(ThinkingIndicator, { speaker: inputSpeaker, startedAt: streamStart }), _jsx(Box, { marginTop: 1, children: _jsx(InputBox, { speaker: inputSpeaker, value: input, onChange: setInput, onSubmit: handleSubmit, disabled: streaming }) }), _jsx(StatusLine, { speaker: inputSpeaker, errorTag: mode === 'work' ? lastWorkError : null })] }));
|
|
799
|
+
}
|
|
800
|
+
async function listProvidersInChat(append) {
|
|
801
|
+
const ids = await configuredProviderIds();
|
|
802
|
+
if (ids.length === 0) {
|
|
803
|
+
append({
|
|
804
|
+
kind: 'info',
|
|
805
|
+
text: 'no providers configured. add one: mod8 add-provider, or mod8 keys set <id>.',
|
|
806
|
+
});
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const lines = ['configured providers:'];
|
|
810
|
+
for (const id of ids) {
|
|
811
|
+
const entry = await resolveConfigured(id);
|
|
812
|
+
if (!entry)
|
|
813
|
+
continue;
|
|
814
|
+
lines.push(` ${id} — ${entry.name} (${entry.apiType}, ${entry.defaultModel})`);
|
|
815
|
+
}
|
|
816
|
+
lines.push('');
|
|
817
|
+
lines.push('use one with: "use <id>" / "ask <id>" / "switch to <id>"');
|
|
818
|
+
append({ kind: 'info', text: lines.join('\n') });
|
|
819
|
+
}
|
|
820
|
+
export async function runChat(opts = {}) {
|
|
821
|
+
// Resolve the session FIRST so that bad ids fail fast with a clear error,
|
|
822
|
+
// even when the user hasn't configured an API key yet.
|
|
823
|
+
let session;
|
|
824
|
+
if (opts.fresh) {
|
|
825
|
+
session = await createSession();
|
|
826
|
+
}
|
|
827
|
+
else if (opts.sessionId) {
|
|
828
|
+
const loaded = await loadSession(opts.sessionId);
|
|
829
|
+
if (!loaded) {
|
|
830
|
+
console.error(chalk.red('mod8: ') + `no session with id "${opts.sessionId}". try: mod8 list`);
|
|
831
|
+
process.exit(1);
|
|
832
|
+
}
|
|
833
|
+
session = loaded;
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
const recent = await getMostRecentSession();
|
|
837
|
+
session = recent ?? (await createSession());
|
|
838
|
+
}
|
|
839
|
+
// Host mode requires Anthropic; check after session resolution so a bad id
|
|
840
|
+
// surfaces "no session" instead of "missing key".
|
|
841
|
+
const hostEntry = await resolveConfigured(HOST_PROVIDER_ID);
|
|
842
|
+
if (!hostEntry) {
|
|
843
|
+
console.error('mod8: No Anthropic key configured. Run: mod8 keys set anthropic, or set ANTHROPIC_API_KEY.');
|
|
844
|
+
process.exit(1);
|
|
845
|
+
}
|
|
846
|
+
if (session.messages.length > 0) {
|
|
847
|
+
const turnCount = session.messages.filter((m) => m.role === 'assistant').length;
|
|
848
|
+
const turnLabel = turnCount === 1 ? '1 turn' : `${turnCount} turns`;
|
|
849
|
+
console.log(chalk.dim(`resuming session ${session.id} from ${humanTimeAgo(session.lastActivity)} · ${turnLabel}`));
|
|
850
|
+
}
|
|
851
|
+
if (process.env.MOD8_CHAT_DEBUG === '1') {
|
|
852
|
+
console.error(`[debug] starting chat: session=${session.id}, messages=${session.messages.length}, pid=${process.pid}`);
|
|
853
|
+
}
|
|
854
|
+
render(_jsx(App, { session: session }));
|
|
855
|
+
}
|