converse-mcp-server 4.0.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -51,6 +51,11 @@ OPENROUTER_API_KEY=your_openrouter_api_key_here
51
51
  # OPENROUTER_REFERER=https://github.com/FallDownTheSystem/converse
52
52
  # OPENROUTER_TITLE=Converse
53
53
 
54
+ # Get your TypeSafe API key from: https://typesafe.ai
55
+ # Enables the decide tool's native host for Jev decision models. Without it,
56
+ # decide reaches Jev through OPENROUTER_API_KEY instead.
57
+ TYPESAFE_API_KEY=your_typesafe_api_key_here
58
+
54
59
  # ============================================
55
60
  # Codex Configuration (Optional)
56
61
  # ============================================
package/README.md CHANGED
@@ -182,6 +182,25 @@ Cancel running asynchronous operations when needed.
182
182
  }
183
183
  ```
184
184
 
185
+ ### 4. Decide Tool
186
+
187
+ Ask a System One decision model (TypeSafe's Jev) typed questions about a state and get calibrated answers instead of text: a yes probability (`noul`), a chosen option with per-option probabilities (`choice`), or a rubric position with per-level probabilities (`score`). Batch many questions into one call; they are judged in parallel against the same state. Needs `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY` (TypeSafe first, falling back to OpenRouter).
188
+
189
+ ```javascript
190
+ {
191
+ "state": { "message": "I was charged twice for order A-104. Please fix this ASAP." },
192
+ "questions": {
193
+ "urgent": { "type": "noul", "instructions": "Does the message convey urgency?" },
194
+ "team": { "type": "choice", "instructions": "Which team should handle this?",
195
+ "criteria": { "billing": "Payments, refunds", "technical": "Bugs, outages" } },
196
+ "frustration": { "type": "score", "instructions": "How frustrated is the customer?",
197
+ "criteria": ["Calm", "Frustrated", "Very angry"] }
198
+ }
199
+ }
200
+ ```
201
+
202
+ See [docs/API.md](docs/API.md#decide-tool) for the full schema, model routing, and usage guidance.
203
+
185
204
  ## 🤖 AI Summarization Feature
186
205
 
187
206
  When enabled, the server automatically generates intelligent titles and summaries for better context understanding:
package/docs/API.md CHANGED
@@ -366,6 +366,110 @@ The response renders a human-readable status (start time, elapsed time, turn/mod
366
366
 
367
367
  Only jobs in a `queued` or `running` state can be cancelled; already-completed, failed, or cancelled jobs return a non-cancellable status.
368
368
 
369
+ ## Decide Tool
370
+
371
+ **Description**: Ask a System One decision model (TypeSafe's Jev family) typed questions about a state. Decision models return calibrated probabilities, never text, so they have their own tool and their own providers: `chat` routing never reaches them, and `decide` never reaches a chat model.
372
+
373
+ ### Request Schema
374
+
375
+ ```json
376
+ {
377
+ "type": "object",
378
+ "properties": {
379
+ "state": { "anyOf": [{ "type": "string" }, { "type": "object" }, { "type": "array" }] },
380
+ "questions": {
381
+ "type": "object",
382
+ "additionalProperties": {
383
+ "type": "object",
384
+ "properties": {
385
+ "type": { "type": "string", "enum": ["noul", "choice", "score"] },
386
+ "instructions": { "anyOf": [{ "type": "string" }, { "type": "object" }, { "type": "array" }] },
387
+ "criteria": { "anyOf": [{ "type": "object" }, { "type": "array" }] }
388
+ },
389
+ "required": ["type", "instructions"]
390
+ }
391
+ },
392
+ "model": { "type": "string", "description": "Default: \"auto\"" },
393
+ "files": { "type": "array", "items": { "type": "string" } }
394
+ },
395
+ "required": ["questions"],
396
+ "additionalProperties": false
397
+ }
398
+ ```
399
+
400
+ - **`state`**: the material to judge. An object with descriptively named fields works best; use an array for sequences such as chat messages. Optional when `files` is given.
401
+ - **`questions`**: named questions, each judged in parallel and in isolation against the same state. The name is your own label and becomes the answer key. Batching many questions into one call adds almost no latency or cost.
402
+ - **`files`**: text files added to the state as `{ "files": { "<path>": "<content>" } }`. When `state` is also given, it moves to `input`. Line ranges (`file.txt{10:50}`) are supported; images are rejected.
403
+
404
+ | Type | `criteria` | Answer |
405
+ |---|---|---|
406
+ | `noul` | Optional `{ "true": "...", "false": "..." }` | `noul`: probability 0..1 of yes |
407
+ | `choice` | Required `{ "<option>": "description" \| null }`, 2–255 options | `choice`, per-option `probabilities`, `confidence` |
408
+ | `score` | Required ordered array of levels, lowest first, 2–10 levels | `score` (probability-weighted level position), `legend`, per-level `probabilities`, `confidence` |
409
+
410
+ `instructions` and criteria descriptions may be objects or arrays that bundle reference data with the question; refer to their fields by `` `name` `` in the text. Questions are validated before any request is sent.
411
+
412
+ ### Models and Providers
413
+
414
+ | Provider | Key | Models |
415
+ |---|---|---|
416
+ | `typesafe` (native, `https://api.typesafe.ai/v1/systemone`) | `TYPESAFE_API_KEY` | `jev-latest`, `jev-1.13.0` (alias `jev-1.13`), `jev-preview`, any versioned `jev-X.Y.Z` |
417
+ | `openrouter` (`https://openrouter.ai/api/v1/systemone`) | `OPENROUTER_API_KEY` | `~typesafe/jev-latest` (alias `jev-latest`), `typesafe/jev-1.13` (aliases `jev-1.13`, `jev-1.13.0`), any `vendor/model` slug |
418
+
419
+ - `auto` (default): TypeSafe's default model, falling back to OpenRouter's.
420
+ - A bare name such as `jev-1.13` goes to every configured provider that serves it, native first, and each provider receives its own model ID. OpenRouter does not serve `jev-preview` or patch-level IDs other than those listed above.
421
+ - `typesafe:jev-1.13.0` or `openrouter:~typesafe/jev-latest` pins one provider.
422
+
423
+ Each provider call retries timeouts, 408, 429 and 5xx with backoff, honoring `Retry-After`. Auth failures, exhausted retries and malformed responses fall back to the next provider. A 400/422 request fault stops immediately, because every host would reject it the same way.
424
+
425
+ ### Example Usage
426
+
427
+ ```json
428
+ {
429
+ "state": { "message": "I was charged twice for order A-104. Please fix this ASAP." },
430
+ "questions": {
431
+ "urgent": { "type": "noul", "instructions": "Does the message convey urgency?" },
432
+ "team": {
433
+ "type": "choice",
434
+ "instructions": "Which team should handle this?",
435
+ "criteria": { "billing": "Payments, invoicing, refunds", "technical": "Bugs, outages", "sales": null }
436
+ },
437
+ "frustration": {
438
+ "type": "score",
439
+ "instructions": "How frustrated is the customer?",
440
+ "criteria": ["Calm", "Frustrated", "Very angry"]
441
+ }
442
+ }
443
+ }
444
+ ```
445
+
446
+ ### Response Format
447
+
448
+ A one-line summary per answer (options at 0.00 omitted), followed by the answers with full distributions, in the same JSON shape whichever provider served them:
449
+
450
+ ````
451
+ Decision · typesafe/jev-1.13-20260917 via OpenRouter · 394 input tokens · $0.000017
452
+ - urgent (noul): 0.97
453
+ - team (choice): billing · confidence 1.00 · billing 1.00
454
+ - frustration (score): 1.24 on 0–2 · confidence 0.64 · 1 Frustrated 0.76, 2 Very angry 0.24
455
+
456
+ ```json
457
+ {
458
+ "model": "typesafe/jev-1.13-20260917",
459
+ "provider": "openrouter",
460
+ "answers": { "urgent": { "type": "noul", "noul": 0.97 }, "...": {} },
461
+ "usage": { "input_tokens": 394, "output_tokens": 70, "cost": 0.000016548 },
462
+ "id": "gen-dec-..."
463
+ }
464
+ ```
465
+ ````
466
+
467
+ `usage.cost` is reported by OpenRouter only (`null` from TypeSafe). A fallback is noted under the summary line.
468
+
469
+ ### Usage Guidance
470
+
471
+ Jev reads questions literally and is weak at counting, arithmetic, date comparison, and multi-hop reasoning; do those in code and ask narrow, atomic questions. Split compound judgments into separate questions and combine them in code. Treat low `confidence` as a signal to escalate to a generative model or a human. Limits: text only, about 64k tokens per request and 32k for the state plus the longest single question.
472
+
369
473
  ## Supported Models
370
474
 
371
475
  Provide models as plain name strings in the `models` array. Each entry is `auto`, a provider name (its default model), `provider:model` (that model on that provider only), or a bare model ID/alias (the first set-up provider that offers it). Names that match no provider's list are rejected with suggestions. See [Model Selection](#model-selection) for the full rules.
@@ -714,6 +818,7 @@ ANTHROPIC_API_KEY=sk-ant-...
714
818
  MISTRAL_API_KEY=...
715
819
  DEEPSEEK_API_KEY=...
716
820
  OPENROUTER_API_KEY=sk-or-...
821
+ TYPESAFE_API_KEY=... # decide tool only
717
822
  ```
718
823
 
719
824
  **MCP client configuration:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "converse-mcp-server",
3
- "version": "4.0.0",
3
+ "version": "4.1.1",
4
4
  "description": "Converse MCP Server - Converse with other LLMs with chat and consensus tools",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/config.js CHANGED
@@ -214,6 +214,12 @@ const CONFIG_SCHEMA = {
214
214
  secret: true,
215
215
  description: 'OpenRouter API key',
216
216
  },
217
+ TYPESAFE_API_KEY: {
218
+ type: 'string',
219
+ required: false,
220
+ secret: true,
221
+ description: 'TypeSafe API key (System One decision models for the decide tool)',
222
+ },
217
223
  },
218
224
 
219
225
  // Provider-specific configuration
@@ -707,7 +713,7 @@ export async function loadConfig() {
707
713
 
708
714
  if (availableKeys.length === 0 && !hasVertexAI && !hasSdkProvider) {
709
715
  errors.push(
710
- 'At least one API key must be configured: OPENAI_API_KEY, XAI_API_KEY, GOOGLE_API_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY, MISTRAL_API_KEY, DEEPSEEK_API_KEY, or OPENROUTER_API_KEY. Alternatively, configure Google Vertex AI or use an SDK-based provider (codex, claude, copilot) or the Antigravity CLI (gemini-cli).',
716
+ 'At least one API key must be configured: OPENAI_API_KEY, XAI_API_KEY, GOOGLE_API_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY, MISTRAL_API_KEY, DEEPSEEK_API_KEY, OPENROUTER_API_KEY, or TYPESAFE_API_KEY. Alternatively, configure Google Vertex AI or use an SDK-based provider (codex, claude, copilot) or the Antigravity CLI (gemini-cli).',
711
717
  );
712
718
  }
713
719
 
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Decision Providers
3
+ *
4
+ * Hosts of System One decision models (TypeSafe's Jev family). These are kept
5
+ * apart from the chat provider registry on purpose: a decision model takes a
6
+ * state plus typed questions and returns probabilities, never text, so it must
7
+ * never be reachable from `chat` routing ("auto", bare names, failover), and
8
+ * the `decide` tool must never reach a chat model.
9
+ *
10
+ * Spec grammar mirrors chat routing:
11
+ * - `auto` (or empty) — every configured provider's default, in priority order
12
+ * - `provider` / `provider:` — that provider's default model
13
+ * - `provider:model` — that provider only
14
+ * - `model` — every provider that serves the name, in priority order; the
15
+ * first is used and the rest are failover candidates
16
+ */
17
+
18
+ import { getCustomHeaders as getOpenRouterAttributionHeaders } from '../providers/openrouter.js';
19
+
20
+ /**
21
+ * Model catalogs map each provider's own model ID to the names that route to
22
+ * it. OpenRouter only knows `~typesafe/jev-latest` and `typesafe/jev-1.13`
23
+ * (it rejects `jev-1.13.0` and `jev-preview`), so native IDs are translated
24
+ * per provider rather than by a blanket prefix rewrite.
25
+ */
26
+ export const DECISION_PROVIDERS = {
27
+ typesafe: {
28
+ name: 'typesafe',
29
+ label: 'TypeSafe',
30
+ baseURL: 'https://api.typesafe.ai',
31
+ apiKeyEnv: 'TYPESAFE_API_KEY',
32
+ defaultModel: 'jev-latest',
33
+ models: {
34
+ 'jev-latest': ['jev', '~typesafe/jev-latest'],
35
+ 'jev-1.13.0': ['jev-1.13', 'typesafe/jev-1.13'],
36
+ 'jev-preview': [],
37
+ },
38
+ // TypeSafe accepts every published versioned ID, listed or not.
39
+ passthrough: (name) => /^jev-\d+\.\d+(\.\d+)?$/i.test(name),
40
+ headers: (config) => ({ Authorization: `Bearer ${config.apiKeys.typesafe}` }),
41
+ isAvailable: (config) => Boolean(config?.apiKeys?.typesafe),
42
+ },
43
+ openrouter: {
44
+ name: 'openrouter',
45
+ label: 'OpenRouter',
46
+ baseURL: 'https://openrouter.ai/api',
47
+ apiKeyEnv: 'OPENROUTER_API_KEY',
48
+ defaultModel: '~typesafe/jev-latest',
49
+ models: {
50
+ '~typesafe/jev-latest': ['jev-latest', 'jev'],
51
+ 'typesafe/jev-1.13': ['jev-1.13', 'jev-1.13.0'],
52
+ },
53
+ // Decision models OpenRouter adds later are reachable by full slug.
54
+ passthrough: (name) => name.includes('/'),
55
+ headers: (config) => ({
56
+ Authorization: `Bearer ${config.apiKeys.openrouter}`,
57
+ ...getOpenRouterAttributionHeaders(config),
58
+ }),
59
+ isAvailable: (config) => Boolean(config?.apiKeys?.openrouter),
60
+ },
61
+ };
62
+
63
+ /** Failover order: the native host first, then OpenRouter. */
64
+ export const DECISION_PROVIDER_PRIORITY = ['typesafe', 'openrouter'];
65
+
66
+ /**
67
+ * Resolve a name against one provider's catalog, then its passthrough rule.
68
+ * @returns {string|null} The model ID to send to that provider
69
+ */
70
+ export function findDecisionModel(provider, name) {
71
+ const wanted = String(name).trim().toLowerCase();
72
+ if (!wanted) return null;
73
+ for (const [id, aliases] of Object.entries(provider.models)) {
74
+ if (id.toLowerCase() === wanted || aliases.some((a) => a.toLowerCase() === wanted)) {
75
+ return id;
76
+ }
77
+ }
78
+ return provider.passthrough(String(name).trim()) ? String(name).trim() : null;
79
+ }
80
+
81
+ function setupHint(provider) {
82
+ return `set ${provider.apiKeyEnv}`;
83
+ }
84
+
85
+ function knownModels() {
86
+ const names = new Set();
87
+ for (const providerName of DECISION_PROVIDER_PRIORITY) {
88
+ const provider = DECISION_PROVIDERS[providerName];
89
+ for (const [id, aliases] of Object.entries(provider.models)) {
90
+ names.add(id);
91
+ aliases.forEach((a) => names.add(a));
92
+ }
93
+ }
94
+ return [...names];
95
+ }
96
+
97
+ function ok(candidates) {
98
+ return { status: 'ok', candidates, error: null };
99
+ }
100
+
101
+ function fail(status, error) {
102
+ return { status, candidates: [], error };
103
+ }
104
+
105
+ /**
106
+ * Resolve a decision model spec into an ordered candidate list.
107
+ * @param {string} spec - Model spec (see module grammar)
108
+ * @param {object} config - Server configuration (for API keys)
109
+ * @returns {{ status: 'ok'|'unknown'|'unavailable', candidates: Array<{ providerName: string, provider: object, model: string }>, error: string|null }}
110
+ */
111
+ export function resolveDecisionModel(spec, config) {
112
+ const raw = String(spec ?? '').trim();
113
+ const namespaces = DECISION_PROVIDER_PRIORITY.join(', ');
114
+
115
+ if (!raw || raw.toLowerCase() === 'auto') {
116
+ const candidates = DECISION_PROVIDER_PRIORITY
117
+ .map((name) => DECISION_PROVIDERS[name])
118
+ .filter((provider) => provider.isAvailable(config))
119
+ .map((provider) => ({ providerName: provider.name, provider, model: provider.defaultModel }));
120
+ if (candidates.length === 0) {
121
+ return fail(
122
+ 'unavailable',
123
+ `No decision provider is configured: ${DECISION_PROVIDER_PRIORITY.map((n) => `${n} (${setupHint(DECISION_PROVIDERS[n])})`).join('; ')}.`,
124
+ );
125
+ }
126
+ return ok(candidates);
127
+ }
128
+
129
+ const colon = raw.indexOf(':');
130
+ const hasNamespace = colon > 0 && !raw.slice(0, colon).includes('/');
131
+ const namespace = hasNamespace ? raw.slice(0, colon).toLowerCase() : raw.toLowerCase();
132
+
133
+ if (hasNamespace || DECISION_PROVIDERS[namespace]) {
134
+ const provider = DECISION_PROVIDERS[namespace];
135
+ if (!provider) {
136
+ return fail('unknown', `Unknown decision provider "${raw.slice(0, colon)}". Providers: ${namespaces}.`);
137
+ }
138
+ const name = hasNamespace ? raw.slice(colon + 1).trim() : '';
139
+ const model = name ? findDecisionModel(provider, name) : provider.defaultModel;
140
+ if (!model) {
141
+ return fail(
142
+ 'unknown',
143
+ `${provider.label} does not serve "${name}". Models: ${Object.keys(provider.models).join(', ')}.`,
144
+ );
145
+ }
146
+ if (!provider.isAvailable(config)) {
147
+ return fail('unavailable', `${provider.label} is not configured (${setupHint(provider)}).`);
148
+ }
149
+ return ok([{ providerName: provider.name, provider, model }]);
150
+ }
151
+
152
+ const matches = DECISION_PROVIDER_PRIORITY
153
+ .map((providerName) => {
154
+ const provider = DECISION_PROVIDERS[providerName];
155
+ const model = findDecisionModel(provider, raw);
156
+ return model ? { providerName, provider, model } : null;
157
+ })
158
+ .filter(Boolean);
159
+
160
+ if (matches.length === 0) {
161
+ return fail(
162
+ 'unknown',
163
+ `Unknown decision model "${raw}". Use "auto", a provider (${namespaces}), "provider:model", or one of: ${knownModels().join(', ')}.`,
164
+ );
165
+ }
166
+ const available = matches.filter((m) => m.provider.isAvailable(config));
167
+ if (available.length === 0) {
168
+ return fail(
169
+ 'unavailable',
170
+ `"${raw}" is served by ${matches.map((m) => `${m.providerName} (${setupHint(m.provider)})`).join('; ')}, but none is configured.`,
171
+ );
172
+ }
173
+ return ok(available);
174
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * System One HTTP client
3
+ *
4
+ * One client for every host of the System One decision API. TypeSafe serves it
5
+ * natively at `/v1/systemone`; OpenRouter serves the same request/response
6
+ * schema at the same path under its own base URL, so providers differ only in
7
+ * base URL, key, and headers.
8
+ *
9
+ * Plain fetch rather than @typesafe-ai/sdk: the schema is small, the tool
10
+ * needs its own abort signal and error mapping, and the SDK's model listing
11
+ * breaks against OpenRouter.
12
+ */
13
+
14
+ const DEFAULT_TIMEOUT_MS = 60_000;
15
+ const DEFAULT_MAX_RETRIES = 2;
16
+ const BACKOFF_INITIAL_MS = 500;
17
+ const BACKOFF_MAX_MS = 5_000;
18
+ // A longer server-requested wait is better spent failing over to another host.
19
+ const MAX_RETRY_AFTER_MS = 30_000;
20
+
21
+ /**
22
+ * Error from a System One call. `retryable` marks failures worth repeating or
23
+ * failing over (network, timeout, 408/429/5xx, auth); `terminal` marks request
24
+ * faults that every host would reject the same way.
25
+ */
26
+ export class DecisionError extends Error {
27
+ constructor(message, { status = null, retryable = false, terminal = false, requestId = null, retryAfterMs = null } = {}) {
28
+ super(message);
29
+ this.name = 'DecisionError';
30
+ this.status = status;
31
+ this.retryable = retryable;
32
+ this.terminal = terminal;
33
+ this.requestId = requestId;
34
+ this.retryAfterMs = retryAfterMs;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Render a validation issue list (zod-style `[{path, message}]`) as one line
40
+ * per issue; anything else is returned as-is.
41
+ */
42
+ function formatIssues(text) {
43
+ let issues;
44
+ try {
45
+ issues = JSON.parse(text);
46
+ } catch {
47
+ return text;
48
+ }
49
+ if (!Array.isArray(issues) || !issues.every((i) => i && typeof i.message === 'string')) {
50
+ return text;
51
+ }
52
+ return issues
53
+ .map((i) => (Array.isArray(i.path) && i.path.length ? `${i.path.join('.')}: ${i.message}` : i.message))
54
+ .join('; ');
55
+ }
56
+
57
+ /**
58
+ * Extract a readable message from an error body. OpenRouter wraps errors as
59
+ * `{ error: { message } }`; TypeSafe answers `{ detail: { error_type, message } }`,
60
+ * or `{ detail }` as a string or a list of validation issues.
61
+ */
62
+ export function extractErrorMessage(body, rawText) {
63
+ if (typeof body?.detail?.message === 'string') {
64
+ const type = body.detail.error_type ? `${body.detail.error_type}: ` : '';
65
+ return `${type}${body.detail.message}`;
66
+ }
67
+ const nested = body?.error?.message ?? body?.error ?? body?.detail ?? body?.message;
68
+ if (typeof nested === 'string') {
69
+ const inner = nested.match(/^HTTP \d+: (\{.*\})$/s);
70
+ if (inner) {
71
+ try {
72
+ return extractErrorMessage(JSON.parse(inner[1]), inner[1]);
73
+ } catch {
74
+ return nested;
75
+ }
76
+ }
77
+ return formatIssues(nested);
78
+ }
79
+ if (Array.isArray(nested)) {
80
+ return nested
81
+ .map((d) => (d?.loc ? `${d.loc.join('.')}: ${d.msg}` : d?.msg || JSON.stringify(d)))
82
+ .join('; ');
83
+ }
84
+ return rawText?.trim() || 'No error details returned';
85
+ }
86
+
87
+ function parseRetryAfter(headers) {
88
+ const ms = Number(headers.get('retry-after-ms'));
89
+ if (Number.isFinite(ms) && ms > 0) return ms;
90
+ const value = headers.get('retry-after');
91
+ if (!value) return null;
92
+ const seconds = Number(value);
93
+ if (Number.isFinite(seconds)) return seconds * 1000;
94
+ const date = Date.parse(value);
95
+ return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
96
+ }
97
+
98
+ function sleep(ms, signal) {
99
+ return new Promise((resolve, reject) => {
100
+ if (signal?.aborted) {
101
+ reject(signal.reason);
102
+ return;
103
+ }
104
+ const timer = setTimeout(() => {
105
+ signal?.removeEventListener('abort', onAbort);
106
+ resolve();
107
+ }, ms);
108
+ function onAbort() {
109
+ clearTimeout(timer);
110
+ reject(signal.reason);
111
+ }
112
+ signal?.addEventListener('abort', onAbort, { once: true });
113
+ });
114
+ }
115
+
116
+ async function sendOnce({ url, headers, body, signal, timeoutMs }) {
117
+ const attemptSignal = signal
118
+ ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
119
+ : AbortSignal.timeout(timeoutMs);
120
+
121
+ let response;
122
+ try {
123
+ response = await fetch(url, {
124
+ method: 'POST',
125
+ headers: { 'Content-Type': 'application/json', ...headers },
126
+ body: JSON.stringify(body),
127
+ signal: attemptSignal,
128
+ });
129
+ } catch (error) {
130
+ if (signal?.aborted) throw error;
131
+ const timedOut = error?.name === 'TimeoutError';
132
+ throw new DecisionError(
133
+ timedOut ? `Request timed out after ${timeoutMs / 1000}s` : `Connection failed: ${error.message}`,
134
+ { retryable: true },
135
+ );
136
+ }
137
+
138
+ const rawText = await response.text();
139
+ let parsed = null;
140
+ try {
141
+ parsed = rawText ? JSON.parse(rawText) : null;
142
+ } catch {
143
+ parsed = null;
144
+ }
145
+
146
+ if (!response.ok) {
147
+ const status = response.status;
148
+ throw new DecisionError(`HTTP ${status}: ${extractErrorMessage(parsed, rawText)}`, {
149
+ status,
150
+ retryable: status === 408 || status === 429 || status >= 500 || status === 401 || status === 403,
151
+ terminal: status === 400 || status === 422,
152
+ requestId: response.headers.get('x-typesafe-request-id') || response.headers.get('x-generation-id'),
153
+ retryAfterMs: parseRetryAfter(response.headers),
154
+ });
155
+ }
156
+
157
+ if (!parsed || typeof parsed.answers !== 'object' || parsed.answers === null) {
158
+ throw new DecisionError('Malformed response: missing "answers" object', { status: response.status });
159
+ }
160
+ return parsed;
161
+ }
162
+
163
+ /**
164
+ * POST a decision request, retrying transient failures (except auth, which
165
+ * retrying cannot fix) with exponential backoff that honors Retry-After.
166
+ * @param {object} params
167
+ * @param {string} params.baseURL - Host base, e.g. https://api.typesafe.ai
168
+ * @param {object} params.headers - Auth and attribution headers
169
+ * @param {object} params.body - `{ model, state, questions }`
170
+ * @param {AbortSignal} [params.signal] - Caller cancellation
171
+ * @param {number} [params.timeoutMs] - Per-attempt timeout
172
+ * @param {number} [params.maxRetries] - Retries after the first attempt
173
+ * @returns {Promise<object>} Parsed response body
174
+ */
175
+ export async function callSystemOne({
176
+ baseURL,
177
+ headers,
178
+ body,
179
+ signal,
180
+ timeoutMs = DEFAULT_TIMEOUT_MS,
181
+ maxRetries = DEFAULT_MAX_RETRIES,
182
+ }) {
183
+ const url = `${baseURL.replace(/\/+$/, '')}/v1/systemone`;
184
+ for (let attempt = 0; ; attempt++) {
185
+ try {
186
+ return await sendOnce({ url, headers, body, signal, timeoutMs });
187
+ } catch (error) {
188
+ const authFailure = error.status === 401 || error.status === 403;
189
+ if (!(error instanceof DecisionError) || !error.retryable || authFailure || attempt >= maxRetries) {
190
+ throw error;
191
+ }
192
+ if (error.retryAfterMs !== null && error.retryAfterMs > MAX_RETRY_AFTER_MS) {
193
+ throw error;
194
+ }
195
+ const backoff = Math.min(BACKOFF_INITIAL_MS * 2 ** attempt, BACKOFF_MAX_MS);
196
+ await sleep(error.retryAfterMs ?? backoff, signal);
197
+ }
198
+ }
199
+ }
@@ -152,6 +152,27 @@ function generateToolExamplesFromSchema(toolName, inputSchema) {
152
152
  ].join('\n');
153
153
  }
154
154
 
155
+ if (toolName === 'decide') {
156
+ const decideExample = {
157
+ state: { message: 'I was charged twice for order A-104. Please fix this ASAP.' },
158
+ questions: {
159
+ urgent: { type: 'noul', instructions: 'Does the message convey urgency?' },
160
+ team: {
161
+ type: 'choice',
162
+ instructions: 'Which team should handle this?',
163
+ criteria: { billing: 'Payments, invoicing, refunds', technical: 'Bugs, outages', sales: null },
164
+ },
165
+ frustration: {
166
+ type: 'score',
167
+ instructions: 'How frustrated is the customer?',
168
+ criteria: ['Calm', 'Frustrated', 'Very angry'],
169
+ },
170
+ },
171
+ model: 'auto',
172
+ };
173
+ return `\`\`\`json\n${JSON.stringify(decideExample, null, 2)}\n\`\`\``;
174
+ }
175
+
155
176
  if (toolName === 'check_status' || toolName === 'cancel_job') {
156
177
  if (properties.continuation_id)
157
178
  example.continuation_id = SAMPLE_VALUES.continuation_id;
@@ -412,8 +433,9 @@ export function generateHelpContent(config = null) {
412
433
  prop.default !== undefined
413
434
  ? ` (default: ${JSON.stringify(prop.default)})`
414
435
  : '';
436
+ const type = prop.type ?? prop.anyOf?.map((s) => s.type).join(' | ');
415
437
  params.push(
416
- `- **${name}** (${isRequired ? 'required' : 'optional'}, ${prop.type}): ${prop.description}${defaultValue}`,
438
+ `- **${name}** (${isRequired ? 'required' : 'optional'}, ${type}): ${prop.description}${defaultValue}`,
417
439
  );
418
440
  }
419
441
 
@@ -203,7 +203,7 @@ function validateApiKey(apiKey) {
203
203
  * `X-Title`), while still accepting the legacy `openrouterreferer`/
204
204
  * `openroutertitle` config-key spellings as input.
205
205
  */
206
- function getCustomHeaders(config) {
206
+ export function getCustomHeaders(config) {
207
207
  const headers = {};
208
208
 
209
209
  const referer =
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Decide Tool - System One decision models
3
+ *
4
+ * Asks a decision model (TypeSafe's Jev family) typed questions about a state
5
+ * and returns calibrated answers: a probability for yes/no questions, a
6
+ * probability distribution for choices and rubric scores. Decision models
7
+ * never generate text, so this is a separate tool rather than a chat mode.
8
+ */
9
+
10
+ import { createToolResponse, createToolError } from './index.js';
11
+ import { resolveDecisionModel } from '../decisionProviders/index.js';
12
+ import { callSystemOne } from '../decisionProviders/systemOne.js';
13
+ import { validateAllPaths } from '../utils/fileValidator.js';
14
+ import { createLogger } from '../utils/logger.js';
15
+
16
+ const logger = createLogger('decide');
17
+
18
+ const QUESTION_TYPES = ['noul', 'choice', 'score'];
19
+ const QUESTION_FIELDS = ['type', 'instructions', 'criteria'];
20
+ const MAX_CHOICE_OPTIONS = 255;
21
+ const MIN_SCORE_LEVELS = 2;
22
+ const MAX_SCORE_LEVELS = 10;
23
+
24
+ function isPlainObject(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
26
+ }
27
+
28
+ /** Criteria descriptions may be plain text or structured reference data. */
29
+ function isCriterionValue(value) {
30
+ return (typeof value === 'string' && value.trim() !== '') || isPlainObject(value) || Array.isArray(value);
31
+ }
32
+
33
+ function validateState(state) {
34
+ if (typeof state === 'string') {
35
+ return state.trim() ? null : '"state" must not be empty.';
36
+ }
37
+ if (isPlainObject(state) || Array.isArray(state)) return null;
38
+ return '"state" must be a string, an object, or an array.';
39
+ }
40
+
41
+ function validateQuestion(key, question) {
42
+ const at = `questions.${key}`;
43
+ if (!isPlainObject(question)) return `${at} must be an object with "type" and "instructions".`;
44
+
45
+ const unknown = Object.keys(question).filter((f) => !QUESTION_FIELDS.includes(f));
46
+ if (unknown.length) {
47
+ return `${at} has unknown field(s): ${unknown.join(', ')}. Allowed: ${QUESTION_FIELDS.join(', ')}.`;
48
+ }
49
+ if (!QUESTION_TYPES.includes(question.type)) {
50
+ return `${at}.type must be one of: ${QUESTION_TYPES.join(', ')}.`;
51
+ }
52
+
53
+ const { instructions, criteria } = question;
54
+ const hasInstructions =
55
+ (typeof instructions === 'string' && instructions.trim() !== '') ||
56
+ (isPlainObject(instructions) && Object.keys(instructions).length > 0) ||
57
+ (Array.isArray(instructions) && instructions.length > 0);
58
+ if (!hasInstructions) {
59
+ return `${at}.instructions must be a non-empty string, object, or array.`;
60
+ }
61
+
62
+ if (question.type === 'noul') {
63
+ if (criteria === undefined) return null;
64
+ const keys = isPlainObject(criteria) ? Object.keys(criteria).sort() : null;
65
+ if (!keys || keys.join(',') !== 'false,true' || !isCriterionValue(criteria.true) || !isCriterionValue(criteria.false)) {
66
+ return `${at}.criteria for a noul question must be { "true": "...", "false": "..." } with both descriptions.`;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ if (question.type === 'choice') {
72
+ if (!isPlainObject(criteria)) {
73
+ return `${at}.criteria for a choice question must map option names to descriptions (or null).`;
74
+ }
75
+ const options = Object.entries(criteria);
76
+ if (options.length < 2 || options.length > MAX_CHOICE_OPTIONS) {
77
+ return `${at}.criteria must have 2 to ${MAX_CHOICE_OPTIONS} options (got ${options.length}).`;
78
+ }
79
+ const bad = options.filter(([, v]) => v !== null && !isCriterionValue(v)).map(([k]) => k);
80
+ if (bad.length) {
81
+ return `${at}.criteria option(s) ${bad.join(', ')} need a description string, object, array, or null.`;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ if (!Array.isArray(criteria)) {
87
+ return `${at}.criteria for a score question must be an ordered array of level descriptions.`;
88
+ }
89
+ if (criteria.length < MIN_SCORE_LEVELS || criteria.length > MAX_SCORE_LEVELS) {
90
+ return `${at}.criteria must have ${MIN_SCORE_LEVELS} to ${MAX_SCORE_LEVELS} levels (got ${criteria.length}).`;
91
+ }
92
+ if (!criteria.every(isCriterionValue)) {
93
+ return `${at}.criteria levels must each be a non-empty description.`;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * Check questions locally: the API reports schema faults as nested validation
100
+ * dumps, and a local message is both clearer and free.
101
+ * @returns {string|null} First problem found
102
+ */
103
+ export function validateQuestions(questions) {
104
+ if (!isPlainObject(questions) || Object.keys(questions).length === 0) {
105
+ return '"questions" must be an object with at least one named question.';
106
+ }
107
+ for (const [key, question] of Object.entries(questions)) {
108
+ if (!key.trim()) return 'Question names must not be empty.';
109
+ const error = validateQuestion(key, question);
110
+ if (error) return error;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Read files into a `{ path: content }` map. Every file must load as text:
117
+ * a silently missing file would change the state being judged.
118
+ */
119
+ async function loadFiles(files, contextProcessor, config) {
120
+ const validation = await validateAllPaths(
121
+ { files },
122
+ { clientCwd: config?.server?.client_cwd },
123
+ );
124
+ if (!validation.valid) {
125
+ return { error: validation.errors.join('; ') };
126
+ }
127
+
128
+ const result = await contextProcessor.processUnifiedContext(
129
+ { files },
130
+ { enforceSecurityCheck: false, skipSecurityCheck: true, clientCwd: config?.server?.client_cwd },
131
+ );
132
+ const problems = [];
133
+ const contents = {};
134
+ for (const file of result.files) {
135
+ if (file.type === 'error') {
136
+ problems.push(`${file.originalPath}: ${file.error}`);
137
+ } else if (file.type !== 'text') {
138
+ problems.push(`${file.originalPath}: decision models accept text only`);
139
+ } else {
140
+ contents[file.originalPath] = file.content;
141
+ }
142
+ }
143
+ if (result.errors?.length) problems.push(...result.errors.map((e) => e.message));
144
+ return problems.length ? { error: problems.join('; ') } : { contents };
145
+ }
146
+
147
+ function fixed(n) {
148
+ return typeof n === 'number' ? n.toFixed(2) : String(n);
149
+ }
150
+
151
+ /**
152
+ * Options at or rounding to zero are left out of the summary line; the JSON
153
+ * block still carries the full distribution.
154
+ */
155
+ function byProbability(probabilities, label = (k) => k) {
156
+ return Object.entries(probabilities || {})
157
+ .filter(([, p]) => fixed(p) !== '0.00')
158
+ .sort(([, a], [, b]) => b - a)
159
+ .map(([k, p]) => `${label(k)} ${fixed(p)}`)
160
+ .join(', ');
161
+ }
162
+
163
+ function summarizeAnswer(key, answer) {
164
+ const type = answer?.type ?? 'unknown';
165
+ if (type === 'noul') {
166
+ return `- ${key} (noul): ${fixed(answer.noul)}`;
167
+ }
168
+ if (type === 'choice') {
169
+ return `- ${key} (choice): ${answer.choice} · confidence ${fixed(answer.confidence)} · ${byProbability(answer.probabilities)}`;
170
+ }
171
+ if (type === 'score') {
172
+ const levels = Object.keys(answer.legend || answer.probabilities || {});
173
+ const range = levels.length ? ` on 0–${levels.length - 1}` : '';
174
+ const label = (k) => (answer.legend?.[k] ? `${k} ${answer.legend[k]}` : k);
175
+ return `- ${key} (score): ${fixed(answer.score)}${range} · confidence ${fixed(answer.confidence)} · ${byProbability(answer.probabilities, label)}`;
176
+ }
177
+ return `- ${key} (${type}): ${JSON.stringify(answer)}`;
178
+ }
179
+
180
+ function formatResult(response, candidate, failures) {
181
+ const usage = response.usage || {};
182
+ const header = [
183
+ `Decision · ${response.model || candidate.model} via ${candidate.provider.label}`,
184
+ usage.input_tokens !== undefined ? `${usage.input_tokens} input tokens` : null,
185
+ typeof usage.cost === 'number' ? `$${usage.cost.toFixed(6)}` : null,
186
+ ].filter(Boolean).join(' · ');
187
+
188
+ const lines = [header];
189
+ for (const failure of failures) {
190
+ lines.push(`(${failure.provider} failed, fell back: ${failure.message})`);
191
+ }
192
+ lines.push(...Object.entries(response.answers).map(([key, answer]) => summarizeAnswer(key, answer)));
193
+
194
+ const payload = {
195
+ model: response.model ?? candidate.model,
196
+ provider: candidate.providerName,
197
+ answers: response.answers,
198
+ usage: {
199
+ input_tokens: usage.input_tokens ?? null,
200
+ output_tokens: usage.output_tokens ?? null,
201
+ cost: typeof usage.cost === 'number' ? usage.cost : null,
202
+ },
203
+ ...(response.id ? { id: response.id } : {}),
204
+ };
205
+ return `${lines.join('\n')}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
206
+ }
207
+
208
+ /**
209
+ * Decide MCP Tool
210
+ * @param {object} args - Tool arguments
211
+ * @param {string|object|Array} [args.state] - Material to judge
212
+ * @param {object} args.questions - Named typed questions
213
+ * @param {string} [args.model] - Model spec, default "auto"
214
+ * @param {string[]} [args.files] - Text files added to the state
215
+ * @param {object} dependencies - Injected dependencies (config, contextProcessor, signal)
216
+ * @returns {Promise<object>} MCP tool response
217
+ */
218
+ export async function decideTool(args, dependencies) {
219
+ const { config, contextProcessor, signal } = dependencies;
220
+ const { state, questions, model = 'auto', files = [] } = args;
221
+
222
+ if (!Array.isArray(files) || !files.every((f) => typeof f === 'string' && f.trim())) {
223
+ return createToolError('"files" must be an array of file paths.');
224
+ }
225
+ if (state === undefined && files.length === 0) {
226
+ return createToolError('Provide "state", "files", or both: there is nothing to judge.');
227
+ }
228
+ const stateError = state === undefined ? null : validateState(state);
229
+ if (stateError) return createToolError(stateError);
230
+ const questionError = validateQuestions(questions);
231
+ if (questionError) return createToolError(questionError);
232
+
233
+ const route = resolveDecisionModel(model, config);
234
+ if (route.status !== 'ok') return createToolError(route.error);
235
+
236
+ let finalState = state;
237
+ if (files.length > 0) {
238
+ const loaded = await loadFiles(files, contextProcessor, config);
239
+ if (loaded.error) return createToolError(`Could not load files: ${loaded.error}`);
240
+ finalState = state === undefined ? { files: loaded.contents } : { input: state, files: loaded.contents };
241
+ }
242
+
243
+ const failures = [];
244
+ for (const candidate of route.candidates) {
245
+ try {
246
+ const response = await callSystemOne({
247
+ baseURL: candidate.provider.baseURL,
248
+ headers: candidate.provider.headers(config),
249
+ body: { model: candidate.model, state: finalState, questions },
250
+ signal,
251
+ });
252
+ return createToolResponse(formatResult(response, candidate, failures));
253
+ } catch (error) {
254
+ if (signal?.aborted) return createToolError('Decision request cancelled.');
255
+ logger.error('Decision request failed', { provider: candidate.providerName, model: candidate.model, error: error.message });
256
+ failures.push({ provider: candidate.providerName, message: error.message });
257
+ if (error.terminal) break;
258
+ }
259
+ }
260
+
261
+ const detail = failures.map((f) => `${f.provider}: ${f.message}`).join('; ');
262
+ return createToolError(`Decision request failed (${detail})`);
263
+ }
264
+
265
+ decideTool.description =
266
+ 'DECIDE — ask a System One decision model (TypeSafe Jev) typed questions about a state and get calibrated answers, not text. ' +
267
+ 'Question types: "noul" (yes/no → probability 0..1), "choice" (pick one of 2–255 named options → choice, per-option probabilities, confidence), ' +
268
+ '"score" (ordered rubric of 2–10 levels → weighted position, per-level probabilities, confidence). ' +
269
+ 'Batch independent questions over the same state into one call: they are judged in parallel and in isolation, so none sees another\'s answer; each extra question adds its own input tokens. ' +
270
+ 'Best for fast semantic judgments (classify, route, select, verify, rank). Ask one narrow, coherent judgment per question, with its full meaning in the question; ' +
271
+ 'split independently useful dimensions, but a bounded action choice or contextual interpretation is a valid single question. Do counting, arithmetic, and date comparison in code. ' +
272
+ 'confidence measures how concentrated the distribution is, not permission to act: take the top option to pick a best, and treat a noul near 0.5 as "yes and no equally likely". ' +
273
+ 'Text only, no explanations are returned. ' +
274
+ 'Limits: ~64k tokens per request, ~32k for state plus the longest question.';
275
+
276
+ decideTool.inputSchema = {
277
+ type: 'object',
278
+ properties: {
279
+ state: {
280
+ anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }],
281
+ description:
282
+ 'The material to judge: plain text, or JSON (an object with descriptively named fields is best; an array for sequences such as messages). Optional when "files" is given.',
283
+ },
284
+ questions: {
285
+ type: 'object',
286
+ description:
287
+ 'Named questions, all answered against the same state. The name is your own label and is returned as the answer key. ' +
288
+ 'Each question: { "type": "noul"|"choice"|"score", "instructions": string|object|array, "criteria": ... }. ' +
289
+ 'criteria — noul: optional { "true": "...", "false": "..." }; choice: required { "<option>": "description" | null } (2–255 options); ' +
290
+ 'score: required ordered array of level descriptions, lowest first (2–10 levels). ' +
291
+ 'instructions may be an object bundling the question with reference data, referenced by `name` in the text. ' +
292
+ 'Example: { "team": { "type": "choice", "instructions": "Which team should handle this?", "criteria": { "billing": "Payments, refunds", "technical": "Bugs, outages" } } }',
293
+ additionalProperties: {
294
+ type: 'object',
295
+ properties: {
296
+ type: { type: 'string', enum: QUESTION_TYPES },
297
+ instructions: { anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }] },
298
+ criteria: { anyOf: [{ type: 'object' }, { type: 'array' }] },
299
+ },
300
+ required: ['type', 'instructions'],
301
+ additionalProperties: false,
302
+ },
303
+ },
304
+ model: {
305
+ type: 'string',
306
+ description:
307
+ 'Decision model. "auto" (default): TypeSafe, falling back to OpenRouter. "jev-latest", "jev-1.13": first configured provider that serves it, with fallback. ' +
308
+ '"typesafe:jev-1.13.0", "openrouter:~typesafe/jev-latest": that provider only. Providers: typesafe (TYPESAFE_API_KEY), openrouter (OPENROUTER_API_KEY).',
309
+ },
310
+ files: {
311
+ type: 'array',
312
+ items: { type: 'string' },
313
+ description:
314
+ 'Text files added to the state as { "files": { "<path>": "<content>" } }; a given state moves to "input". Supports line ranges: file.txt{10:50}. Images are rejected.',
315
+ },
316
+ },
317
+ required: ['questions'],
318
+ additionalProperties: false,
319
+ };
@@ -9,6 +9,7 @@
9
9
  import { chatTool } from './chat.js';
10
10
  import { checkStatusTool } from './checkStatus.js';
11
11
  import { cancelJobTool } from './cancelJob.js';
12
+ import { decideTool } from './decide.js';
12
13
 
13
14
  /**
14
15
  * Tool registry map
@@ -19,6 +20,7 @@ const tools = {
19
20
  chat: chatTool,
20
21
  check_status: checkStatusTool,
21
22
  cancel_job: cancelJobTool,
23
+ decide: decideTool,
22
24
  };
23
25
 
24
26
  /**