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.
Files changed (86) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +239 -0
  4. package/bin/mod8.js +2 -0
  5. package/dist/cli.js +302 -0
  6. package/dist/commands/addProvider.js +105 -0
  7. package/dist/commands/all.js +158 -0
  8. package/dist/commands/chat.js +855 -0
  9. package/dist/commands/config.js +29 -0
  10. package/dist/commands/devAuthStatus.js +34 -0
  11. package/dist/commands/devHostAsk.js +51 -0
  12. package/dist/commands/devHostSystem.js +15 -0
  13. package/dist/commands/devResolve.js +54 -0
  14. package/dist/commands/devSimulate.js +235 -0
  15. package/dist/commands/devWorkAsk.js +55 -0
  16. package/dist/commands/intentRouting.js +280 -0
  17. package/dist/commands/keys.js +55 -0
  18. package/dist/commands/list.js +27 -0
  19. package/dist/commands/login.js +147 -0
  20. package/dist/commands/logout.js +17 -0
  21. package/dist/commands/prompt.js +63 -0
  22. package/dist/commands/providers.js +30 -0
  23. package/dist/commands/verify.js +5 -0
  24. package/dist/input/compose.js +37 -0
  25. package/dist/input/files.js +49 -0
  26. package/dist/input/stdin.js +14 -0
  27. package/dist/providers/anthropic.js +115 -0
  28. package/dist/providers/displayName.js +25 -0
  29. package/dist/providers/errorHints.js +175 -0
  30. package/dist/providers/generic.js +331 -0
  31. package/dist/providers/genericChat.js +265 -0
  32. package/dist/providers/google.js +63 -0
  33. package/dist/providers/hostSystem.js +173 -0
  34. package/dist/providers/index.js +38 -0
  35. package/dist/providers/mock.js +87 -0
  36. package/dist/providers/modelResolution.js +42 -0
  37. package/dist/providers/openai.js +75 -0
  38. package/dist/providers/pricing.js +47 -0
  39. package/dist/providers/proxy.js +148 -0
  40. package/dist/providers/registry.js +196 -0
  41. package/dist/providers/types.js +1 -0
  42. package/dist/providers/workSystem.js +33 -0
  43. package/dist/storage/auth.js +65 -0
  44. package/dist/storage/config.js +35 -0
  45. package/dist/storage/keys.js +59 -0
  46. package/dist/storage/providers.js +337 -0
  47. package/dist/storage/sessions.js +150 -0
  48. package/dist/types.js +9 -0
  49. package/dist/util/debug.js +79 -0
  50. package/dist/util/errors.js +157 -0
  51. package/dist/util/prompt.js +111 -0
  52. package/dist/util/secrets.js +110 -0
  53. package/dist/util/text.js +53 -0
  54. package/dist/util/time.js +25 -0
  55. package/dist/verify/runner.js +437 -0
  56. package/package.json +69 -0
  57. package/specs/all-mode.yaml +44 -0
  58. package/specs/behavior/auto-fallback.yaml +49 -0
  59. package/specs/behavior/bare-name-routing.yaml +223 -0
  60. package/specs/behavior/bare-paste-confirm.yaml +125 -0
  61. package/specs/behavior/env-var-respected.yaml +108 -0
  62. package/specs/behavior/error-fidelity.yaml +92 -0
  63. package/specs/behavior/error-hints.yaml +160 -0
  64. package/specs/behavior/fresh-vs-resume.yaml +94 -0
  65. package/specs/behavior/fuzzy-match.yaml +208 -0
  66. package/specs/behavior/host-self-knowledge-fresh.yaml +66 -0
  67. package/specs/behavior/intent-no-mismatch.yaml +115 -0
  68. package/specs/behavior/login-logout.yaml +97 -0
  69. package/specs/behavior/no-model-allowlist.yaml +80 -0
  70. package/specs/behavior/paste-key.yaml +342 -0
  71. package/specs/behavior/provider-switching.yaml +186 -0
  72. package/specs/behavior/providers-json-respected.yaml +106 -0
  73. package/specs/behavior/self-knowledge.yaml +119 -0
  74. package/specs/behavior/stress-session.yaml +226 -0
  75. package/specs/behavior/switch-back-when-failing.yaml +90 -0
  76. package/specs/behavior/work-character.yaml +109 -0
  77. package/specs/chat-meta.yaml +349 -0
  78. package/specs/chat-startup.yaml +148 -0
  79. package/specs/chat.yaml +91 -0
  80. package/specs/config.yaml +42 -0
  81. package/specs/install.yaml +112 -0
  82. package/specs/keys.yaml +81 -0
  83. package/specs/one-shot.yaml +65 -0
  84. package/specs/pipe-and-files.yaml +40 -0
  85. package/specs/providers.yaml +172 -0
  86. 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
+ }