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,29 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getConfig, updateConfig, CONFIG_FILE_PATH } from '../storage/config.js';
|
|
3
|
+
import { KNOWN_PROVIDERS } from '../providers/registry.js';
|
|
4
|
+
import { isKnownOrConfigured } from '../storage/providers.js';
|
|
5
|
+
export async function configGet() {
|
|
6
|
+
const config = await getConfig();
|
|
7
|
+
const defaultStr = config.default ?? 'anthropic';
|
|
8
|
+
const defaultSource = config.default ? 'configured' : 'fallback';
|
|
9
|
+
const consentStr = config.allConsent ? 'given' : 'not yet (first --all will prompt)';
|
|
10
|
+
console.log();
|
|
11
|
+
console.log(` default: ${chalk.bold(defaultStr)} ${chalk.dim(`(${defaultSource})`)}`);
|
|
12
|
+
console.log(` consent: ${chalk.dim(consentStr)}`);
|
|
13
|
+
console.log();
|
|
14
|
+
console.log(chalk.dim(`Stored at ${CONFIG_FILE_PATH}`));
|
|
15
|
+
}
|
|
16
|
+
export async function configSet(key, value) {
|
|
17
|
+
if (key !== 'default') {
|
|
18
|
+
console.error(chalk.red(`Unknown config key '${key}'.`) + ' Available keys: default.');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
if (!(await isKnownOrConfigured(value))) {
|
|
22
|
+
const known = KNOWN_PROVIDERS.map((p) => p.id).join(', ');
|
|
23
|
+
console.error(chalk.red(`Unknown provider '${value}'.`) +
|
|
24
|
+
` Built-in: ${known}.\n Or configure a custom one first: mod8 add-provider`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
await updateConfig({ default: value });
|
|
28
|
+
console.log(chalk.green('✓') + ` Default provider set to ${value}`);
|
|
29
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mod8 dev:auth-status` — print the resolved routing decision the CLI
|
|
3
|
+
* would make right now: which auth.json (if any) is loaded + which
|
|
4
|
+
* provider ids would route through the proxy.
|
|
5
|
+
*
|
|
6
|
+
* Pure (no network). Used by the login behavioral spec to verify the
|
|
7
|
+
* auth.json round-trip + provider-routing predicate without spinning up
|
|
8
|
+
* the proxy.
|
|
9
|
+
*/
|
|
10
|
+
import { readAuth, AUTH_FILE_PATH } from '../storage/auth.js';
|
|
11
|
+
import { toProxyProviderId } from '../providers/proxy.js';
|
|
12
|
+
const PROBE_IDS = ['anthropic', 'openai', 'google', 'deepseek', 'mistral', 'custom-foo'];
|
|
13
|
+
export async function devAuthStatus() {
|
|
14
|
+
const auth = await readAuth();
|
|
15
|
+
process.stdout.write(`authFile=${AUTH_FILE_PATH}\n`);
|
|
16
|
+
if (!auth) {
|
|
17
|
+
process.stdout.write('authed=false\n');
|
|
18
|
+
process.stdout.write('mode=local\n');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// Mask the key — never echo a full secret, same rule as the rest of the CLI.
|
|
22
|
+
const masked = auth.mod8Key.length > 16
|
|
23
|
+
? auth.mod8Key.slice(0, 12) + '…' + auth.mod8Key.slice(-4)
|
|
24
|
+
: auth.mod8Key.slice(0, 12) + '…';
|
|
25
|
+
process.stdout.write('authed=true\n');
|
|
26
|
+
process.stdout.write('mode=proxy\n');
|
|
27
|
+
process.stdout.write(`email=${auth.email ?? '-'}\n`);
|
|
28
|
+
process.stdout.write(`proxyUrl=${auth.proxyUrl}\n`);
|
|
29
|
+
process.stdout.write(`keyMasked=${masked}\n`);
|
|
30
|
+
for (const id of PROBE_IDS) {
|
|
31
|
+
const proxied = toProxyProviderId(id) !== null;
|
|
32
|
+
process.stdout.write(`route id=${id} proxy=${proxied}\n`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mod8 dev:host-ask <prompt>` — non-interactive endpoint that runs a single
|
|
3
|
+
* turn through the host (mod8) system prompt with the user's currently-
|
|
4
|
+
* configured providers injected. Hidden from --help; it exists primarily so
|
|
5
|
+
* `mod8 verify` can assert on real LLM responses to meta questions.
|
|
6
|
+
*
|
|
7
|
+
* Useful from the shell too: a quick meta query without entering the REPL.
|
|
8
|
+
*/
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { streamProviderChat } from '../providers/genericChat.js';
|
|
11
|
+
import { readHostContext, buildHostSystem } from '../providers/hostSystem.js';
|
|
12
|
+
import { classifyError } from '../util/errors.js';
|
|
13
|
+
import { formatStats } from './prompt.js';
|
|
14
|
+
const HOST_PROVIDER_ID = 'anthropic';
|
|
15
|
+
export async function devHostAsk(prompt) {
|
|
16
|
+
const ctx = await readHostContext();
|
|
17
|
+
const system = buildHostSystem(ctx);
|
|
18
|
+
let usage;
|
|
19
|
+
let lastChar = '';
|
|
20
|
+
try {
|
|
21
|
+
for await (const event of streamProviderChat({
|
|
22
|
+
providerId: HOST_PROVIDER_ID,
|
|
23
|
+
system,
|
|
24
|
+
messages: [{ role: 'user', content: prompt }],
|
|
25
|
+
})) {
|
|
26
|
+
if (event.type === 'text') {
|
|
27
|
+
// Strip handoff tokens so they don't leak into stdout.
|
|
28
|
+
const cleaned = event.delta.replace(/<SWITCH_TO_(WORK|HOST)>/gi, '');
|
|
29
|
+
if (cleaned) {
|
|
30
|
+
process.stdout.write(cleaned);
|
|
31
|
+
lastChar = cleaned[cleaned.length - 1] ?? '';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else if (event.type === 'done') {
|
|
35
|
+
usage = event.usage;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (lastChar !== '\n' && lastChar !== '')
|
|
41
|
+
process.stdout.write('\n');
|
|
42
|
+
console.error(chalk.red('mod8: ') + classifyError(err, HOST_PROVIDER_ID));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
if (lastChar !== '\n')
|
|
46
|
+
process.stdout.write('\n');
|
|
47
|
+
if (usage) {
|
|
48
|
+
console.log();
|
|
49
|
+
console.log(formatStats(usage));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mod8 dev:host-system` — print the host system prompt as it would be
|
|
3
|
+
* assembled RIGHT NOW (with current providers.json state). No LLM call.
|
|
4
|
+
*
|
|
5
|
+
* Behavioral specs use this to verify the host-self-knowledge fix: after a
|
|
6
|
+
* provider is added mid-session via the inline paste-key flow, the next
|
|
7
|
+
* host turn rebuilds the system prompt with the new provider visible —
|
|
8
|
+
* not the stale snapshot taken at chat startup.
|
|
9
|
+
*/
|
|
10
|
+
import { readHostContext, buildHostSystem } from '../providers/hostSystem.js';
|
|
11
|
+
export async function devHostSystem() {
|
|
12
|
+
const ctx = await readHostContext();
|
|
13
|
+
process.stdout.write(buildHostSystem(ctx));
|
|
14
|
+
process.stdout.write('\n');
|
|
15
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mod8 dev:resolve <input>` — runs the chat REPL's intent-routing logic on a
|
|
3
|
+
* single input string and prints the structured result. Used by behavioral
|
|
4
|
+
* specs to test synonym handling, switch-back triggers, and routing edge
|
|
5
|
+
* cases without booting Ink.
|
|
6
|
+
*
|
|
7
|
+
* Match priority (mirrors what chat.tsx does in handleSubmit):
|
|
8
|
+
* 1. parseHostBack — "/mod8", "back to mod8", "mod8", etc.
|
|
9
|
+
* 2. parseCompareWithPrompt / isCompareCommand
|
|
10
|
+
* 3. parseProviderRoute — "use deepseek", "talk with codex", etc.
|
|
11
|
+
*
|
|
12
|
+
* Output format (one line, machine-parseable):
|
|
13
|
+
* host-back rest=<json>
|
|
14
|
+
* compare payload=<json>
|
|
15
|
+
* compare-bare
|
|
16
|
+
* route id=<raw> resolved=<id|null> rest=<json>
|
|
17
|
+
* none
|
|
18
|
+
*/
|
|
19
|
+
import { parseProviderRoute, parseHostBack, parseBareProviderHint, parseCompareWithPrompt, isCompareCommand, } from './intentRouting.js';
|
|
20
|
+
import { resolveProviderHint, strictResolveProviderHint, } from '../storage/providers.js';
|
|
21
|
+
export async function devResolve(input) {
|
|
22
|
+
const back = parseHostBack(input);
|
|
23
|
+
if (back) {
|
|
24
|
+
process.stdout.write(`host-back rest=${JSON.stringify(back.rest)}\n`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const payload = parseCompareWithPrompt(input);
|
|
28
|
+
if (payload) {
|
|
29
|
+
process.stdout.write(`compare payload=${JSON.stringify(payload)}\n`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (isCompareCommand(input)) {
|
|
33
|
+
process.stdout.write(`compare-bare\n`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const route = parseProviderRoute(input);
|
|
37
|
+
if (route) {
|
|
38
|
+
const resolved = await resolveProviderHint(route.id);
|
|
39
|
+
process.stdout.write(`route id=${route.id} resolved=${resolved ?? 'null'} rest=${JSON.stringify(route.rest)}\n`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// Bare-name / first-word / greeting matching — same logic as chat.tsx.
|
|
43
|
+
const bare = parseBareProviderHint(input);
|
|
44
|
+
if (bare) {
|
|
45
|
+
const resolved = bare.resolution === 'strict'
|
|
46
|
+
? await strictResolveProviderHint(bare.name)
|
|
47
|
+
: await resolveProviderHint(bare.name);
|
|
48
|
+
if (resolved) {
|
|
49
|
+
process.stdout.write(`route id=${bare.name} resolved=${resolved} rest=${JSON.stringify(bare.rest)}\n`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
process.stdout.write(`none\n`);
|
|
54
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mod8 dev:simulate` — read inputs from stdin (one per line) and play them
|
|
3
|
+
* through the chat REPL's routing state machine. No LLM calls; no Ink.
|
|
4
|
+
* Lets behavioral specs verify long sequences of switches behave correctly:
|
|
5
|
+
* - banner targets match what would be said
|
|
6
|
+
* - workProviderId resets to default on every host transition
|
|
7
|
+
* - parseHostBack always wins (user can never get stuck)
|
|
8
|
+
* - false positives don't auto-route (no surprise switches)
|
|
9
|
+
* - paste-key flow saves to providers.json without ever leaking the key
|
|
10
|
+
* - bare-paste auto-detect (no consent first) asks then saves
|
|
11
|
+
* - fuzzy match catches typos like "gimini" → google
|
|
12
|
+
*
|
|
13
|
+
* Output format (one line per non-empty input):
|
|
14
|
+
* step=N input=<json> mode=<host|work> provider=<id> action=<...> rest=<json>
|
|
15
|
+
*
|
|
16
|
+
* Inputs are sanitized via sanitizeKeys() BEFORE being printed, so a real
|
|
17
|
+
* pasted key never lands in stdout/stderr/scrollback.
|
|
18
|
+
*
|
|
19
|
+
* Actions: host-back, host-back-noop, route, route-bare, route-greeting,
|
|
20
|
+
* send, compare, compare-bare, slash-clear, slash-providers,
|
|
21
|
+
* paste-consent, paste-saved, paste-rejected, paste-pending,
|
|
22
|
+
* paste-cancelled, fuzzy-route, fuzzy-ask, fuzzy-multi,
|
|
23
|
+
* fuzzy-confirmed, fuzzy-cancelled, route-error
|
|
24
|
+
*/
|
|
25
|
+
import { parseProviderRoute, parseHostBack, parseBareProviderHint, parseCompareWithPrompt, isCompareCommand, parsePasteKeyIntent, isPasteConfirmAffirmative, isAffirmative, isNegative, } from './intentRouting.js';
|
|
26
|
+
import { resolveProviderHint, strictResolveProviderHint, resolveConfigured, saveKeyPreservingEntry, fuzzyResolveProviderHint, } from '../storage/providers.js';
|
|
27
|
+
import { findApiKey, sanitizeKeys } from '../util/secrets.js';
|
|
28
|
+
const DEFAULT_WORK = 'anthropic';
|
|
29
|
+
async function persistKey(key, tpl) {
|
|
30
|
+
await saveKeyPreservingEntry(key, tpl);
|
|
31
|
+
}
|
|
32
|
+
async function tryFuzzyRoute(name, rest, state) {
|
|
33
|
+
if (name.length < 4)
|
|
34
|
+
return null;
|
|
35
|
+
if (isAffirmative(name) || isNegative(name))
|
|
36
|
+
return null;
|
|
37
|
+
const fuzzy = await fuzzyResolveProviderHint(name);
|
|
38
|
+
if (fuzzy.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
if (fuzzy.length > 1) {
|
|
41
|
+
const ids = fuzzy.map((c) => c.id).join(',');
|
|
42
|
+
return { action: `fuzzy-multi ids=${ids}`, rest };
|
|
43
|
+
}
|
|
44
|
+
const m = fuzzy[0];
|
|
45
|
+
const askFirst = m.distance === 2 && name.length <= 4;
|
|
46
|
+
if (askFirst) {
|
|
47
|
+
state.pendingFuzzy = { id: m.id, rest };
|
|
48
|
+
return { action: `fuzzy-ask id=${m.id}`, rest };
|
|
49
|
+
}
|
|
50
|
+
const entry = await resolveConfigured(m.id);
|
|
51
|
+
if (!entry) {
|
|
52
|
+
return { action: `fuzzy-not-configured id=${m.id}`, rest };
|
|
53
|
+
}
|
|
54
|
+
state.mode = 'work';
|
|
55
|
+
state.workProviderId = m.id;
|
|
56
|
+
return { action: `fuzzy-route id=${m.id} distance=${m.distance}`, rest };
|
|
57
|
+
}
|
|
58
|
+
async function simulateStep(input, state) {
|
|
59
|
+
const value = input.trim();
|
|
60
|
+
if (!value)
|
|
61
|
+
return { action: 'empty', rest: '' };
|
|
62
|
+
// === STATE HANDLERS (run on RAW because key detection requires it) ====
|
|
63
|
+
// 1. Awaiting explicit paste-key.
|
|
64
|
+
if (state.awaitingKey) {
|
|
65
|
+
state.awaitingKey = false;
|
|
66
|
+
if (parseHostBack(value)) {
|
|
67
|
+
// fall through to dispatch below
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const found = findApiKey(value);
|
|
71
|
+
if (found) {
|
|
72
|
+
await persistKey(found.key, found.template);
|
|
73
|
+
return { action: `paste-saved id=${found.template.id}`, rest: '' };
|
|
74
|
+
}
|
|
75
|
+
if (isNegative(value)) {
|
|
76
|
+
return { action: 'paste-cancelled', rest: '' };
|
|
77
|
+
}
|
|
78
|
+
return { action: 'paste-rejected', rest: '' };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// 2. Pending bare-paste confirm.
|
|
82
|
+
if (state.pendingKey) {
|
|
83
|
+
const cached = state.pendingKey;
|
|
84
|
+
state.pendingKey = null;
|
|
85
|
+
if (isPasteConfirmAffirmative(value)) {
|
|
86
|
+
await persistKey(cached.rawKey, cached.template);
|
|
87
|
+
return { action: `paste-saved id=${cached.template.id}`, rest: '' };
|
|
88
|
+
}
|
|
89
|
+
if (isNegative(value)) {
|
|
90
|
+
return { action: 'paste-cancelled', rest: '' };
|
|
91
|
+
}
|
|
92
|
+
// Otherwise cancel + fall through — emit an explicit signal so specs
|
|
93
|
+
// can verify the cancellation, then continue dispatching this turn.
|
|
94
|
+
}
|
|
95
|
+
// 3. Pending fuzzy confirm.
|
|
96
|
+
if (state.pendingFuzzy) {
|
|
97
|
+
const cached = state.pendingFuzzy;
|
|
98
|
+
state.pendingFuzzy = null;
|
|
99
|
+
if (isAffirmative(value)) {
|
|
100
|
+
const entry = await resolveConfigured(cached.id);
|
|
101
|
+
if (entry) {
|
|
102
|
+
state.mode = 'work';
|
|
103
|
+
state.workProviderId = cached.id;
|
|
104
|
+
return { action: `fuzzy-confirmed id=${cached.id}`, rest: cached.rest };
|
|
105
|
+
}
|
|
106
|
+
return { action: `fuzzy-not-configured id=${cached.id}`, rest: '' };
|
|
107
|
+
}
|
|
108
|
+
if (isNegative(value)) {
|
|
109
|
+
return { action: 'fuzzy-cancelled', rest: '' };
|
|
110
|
+
}
|
|
111
|
+
// Otherwise fall through to normal dispatch.
|
|
112
|
+
}
|
|
113
|
+
// === BARE-PASTE AUTO-DETECT (host mode, raw input) ====================
|
|
114
|
+
if (state.mode === 'host') {
|
|
115
|
+
const found = findApiKey(value);
|
|
116
|
+
if (found) {
|
|
117
|
+
state.pendingKey = { rawKey: found.key, template: found.template };
|
|
118
|
+
return { action: `paste-pending id=${found.template.id}`, rest: '' };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Slash commands first
|
|
122
|
+
if (value === '/exit' || value === '/quit')
|
|
123
|
+
return { action: 'slash-exit', rest: '' };
|
|
124
|
+
if (value === '/clear')
|
|
125
|
+
return { action: 'slash-clear', rest: '' };
|
|
126
|
+
if (value === '/providers')
|
|
127
|
+
return { action: 'slash-providers', rest: '' };
|
|
128
|
+
// Compare
|
|
129
|
+
if (isCompareCommand(value))
|
|
130
|
+
return { action: 'compare-bare', rest: '' };
|
|
131
|
+
const compare = parseCompareWithPrompt(value);
|
|
132
|
+
if (compare)
|
|
133
|
+
return { action: 'compare', rest: compare };
|
|
134
|
+
// parseHostBack always wins.
|
|
135
|
+
const back = parseHostBack(value);
|
|
136
|
+
if (back) {
|
|
137
|
+
if (state.mode === 'work') {
|
|
138
|
+
state.mode = 'host';
|
|
139
|
+
state.workProviderId = DEFAULT_WORK;
|
|
140
|
+
return { action: 'host-back', rest: back.rest };
|
|
141
|
+
}
|
|
142
|
+
return { action: 'host-back-noop', rest: back.rest };
|
|
143
|
+
}
|
|
144
|
+
// Paste-key intent — runs BEFORE provider routing because phrases like
|
|
145
|
+
// "add a key" don't look like routing but should never be sent to the LLM.
|
|
146
|
+
// Only host mode triggers the consent flow.
|
|
147
|
+
const paste = parsePasteKeyIntent(value);
|
|
148
|
+
if (paste && state.mode === 'host') {
|
|
149
|
+
if (paste.providerHint) {
|
|
150
|
+
const resolved = await resolveProviderHint(paste.providerHint);
|
|
151
|
+
if (resolved) {
|
|
152
|
+
state.awaitingKey = true;
|
|
153
|
+
return { action: `paste-consent target=${resolved}`, rest: '' };
|
|
154
|
+
}
|
|
155
|
+
// Trailing word wasn't a provider — fall through to LLM.
|
|
156
|
+
}
|
|
157
|
+
else if (paste.pronounRef) {
|
|
158
|
+
// Bare pronoun ("save this") with no pendingKey — ambiguous, fall
|
|
159
|
+
// through. In real chat this only triggers from inside the
|
|
160
|
+
// pendingKey state (handled above), so reaching here means the user
|
|
161
|
+
// typed it cold.
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
state.awaitingKey = true;
|
|
165
|
+
return { action: 'paste-consent', rest: '' };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Verb-based routing
|
|
169
|
+
const route = parseProviderRoute(value);
|
|
170
|
+
if (route) {
|
|
171
|
+
const exact = await resolveProviderHint(route.id);
|
|
172
|
+
if (!exact) {
|
|
173
|
+
const fuzzed = await tryFuzzyRoute(route.id, route.rest, state);
|
|
174
|
+
if (fuzzed)
|
|
175
|
+
return fuzzed;
|
|
176
|
+
return { action: `route-error unknown=${route.id}`, rest: route.rest };
|
|
177
|
+
}
|
|
178
|
+
const entry = await resolveConfigured(exact);
|
|
179
|
+
if (!entry) {
|
|
180
|
+
return { action: `route-error not-configured=${exact}`, rest: route.rest };
|
|
181
|
+
}
|
|
182
|
+
state.mode = 'work';
|
|
183
|
+
state.workProviderId = exact;
|
|
184
|
+
return { action: 'route', rest: route.rest };
|
|
185
|
+
}
|
|
186
|
+
// Bare-name / first-word / greeting matching
|
|
187
|
+
const bare = parseBareProviderHint(value);
|
|
188
|
+
if (bare) {
|
|
189
|
+
const resolved = bare.resolution === 'strict'
|
|
190
|
+
? await strictResolveProviderHint(bare.name)
|
|
191
|
+
: await resolveProviderHint(bare.name);
|
|
192
|
+
if (resolved) {
|
|
193
|
+
const entry = await resolveConfigured(resolved);
|
|
194
|
+
if (entry) {
|
|
195
|
+
state.mode = 'work';
|
|
196
|
+
state.workProviderId = resolved;
|
|
197
|
+
const action = bare.resolution === 'full' ? 'route-greeting' : 'route-bare';
|
|
198
|
+
return { action, rest: bare.rest };
|
|
199
|
+
}
|
|
200
|
+
return { action: `route-error not-configured=${resolved}`, rest: bare.rest };
|
|
201
|
+
}
|
|
202
|
+
// Exact failed — try fuzzy before falling through.
|
|
203
|
+
const fuzzed = await tryFuzzyRoute(bare.name, bare.rest, state);
|
|
204
|
+
if (fuzzed)
|
|
205
|
+
return fuzzed;
|
|
206
|
+
// Fall through.
|
|
207
|
+
}
|
|
208
|
+
return { action: 'send', rest: value };
|
|
209
|
+
}
|
|
210
|
+
export async function devSimulate() {
|
|
211
|
+
const chunks = [];
|
|
212
|
+
for await (const chunk of process.stdin) {
|
|
213
|
+
chunks.push(chunk);
|
|
214
|
+
}
|
|
215
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
216
|
+
const lines = text.split('\n').map((l) => l.replace(/\r$/, ''));
|
|
217
|
+
const state = {
|
|
218
|
+
mode: 'host',
|
|
219
|
+
workProviderId: DEFAULT_WORK,
|
|
220
|
+
awaitingKey: false,
|
|
221
|
+
pendingKey: null,
|
|
222
|
+
pendingFuzzy: null,
|
|
223
|
+
};
|
|
224
|
+
let stepNum = 0;
|
|
225
|
+
for (const raw of lines) {
|
|
226
|
+
if (raw.length === 0)
|
|
227
|
+
continue;
|
|
228
|
+
stepNum += 1;
|
|
229
|
+
const result = await simulateStep(raw, state);
|
|
230
|
+
const provider = state.mode === 'work' ? state.workProviderId : 'host';
|
|
231
|
+
const safeInput = sanitizeKeys(raw);
|
|
232
|
+
const safeRest = sanitizeKeys(result.rest);
|
|
233
|
+
process.stdout.write(`step=${stepNum} input=${JSON.stringify(safeInput)} mode=${state.mode} provider=${provider} action=${result.action} rest=${JSON.stringify(safeRest)}\n`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mod8 dev:work-ask <providerId> <prompt>` — non-interactive endpoint that
|
|
3
|
+
* runs a single turn through the WORK-mode system prompt for a given
|
|
4
|
+
* configured provider. Powers behavioral tests for work-mode character
|
|
5
|
+
* (does codex stay in character? does it impersonate mod8?).
|
|
6
|
+
*/
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { streamProviderChat } from '../providers/genericChat.js';
|
|
9
|
+
import { resolveConfigured } from '../storage/providers.js';
|
|
10
|
+
import { buildWorkSystem } from '../providers/workSystem.js';
|
|
11
|
+
import { workerNameFor } from '../providers/displayName.js';
|
|
12
|
+
import { classifyError } from '../util/errors.js';
|
|
13
|
+
import { formatStats } from './prompt.js';
|
|
14
|
+
export async function devWorkAsk(providerId, prompt) {
|
|
15
|
+
const entry = await resolveConfigured(providerId);
|
|
16
|
+
if (!entry) {
|
|
17
|
+
console.error(chalk.red('mod8: ') +
|
|
18
|
+
`provider "${providerId}" is not configured. Run mod8 keys set ${providerId} (or mod8 add-provider).`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const workerName = workerNameFor(providerId, entry.name);
|
|
22
|
+
const system = buildWorkSystem(workerName);
|
|
23
|
+
let usage;
|
|
24
|
+
let lastChar = '';
|
|
25
|
+
try {
|
|
26
|
+
for await (const event of streamProviderChat({
|
|
27
|
+
providerId,
|
|
28
|
+
system,
|
|
29
|
+
messages: [{ role: 'user', content: prompt }],
|
|
30
|
+
})) {
|
|
31
|
+
if (event.type === 'text') {
|
|
32
|
+
const cleaned = event.delta.replace(/<SWITCH_TO_(WORK|HOST)>/gi, '');
|
|
33
|
+
if (cleaned) {
|
|
34
|
+
process.stdout.write(cleaned);
|
|
35
|
+
lastChar = cleaned[cleaned.length - 1] ?? '';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else if (event.type === 'done') {
|
|
39
|
+
usage = event.usage;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (lastChar !== '\n' && lastChar !== '')
|
|
45
|
+
process.stdout.write('\n');
|
|
46
|
+
console.error(chalk.red('mod8: ') + classifyError(err, providerId));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
if (lastChar !== '\n')
|
|
50
|
+
process.stdout.write('\n');
|
|
51
|
+
if (usage) {
|
|
52
|
+
console.log();
|
|
53
|
+
console.log(formatStats(usage));
|
|
54
|
+
}
|
|
55
|
+
}
|