mio-agent-runtime 0.5.23 → 0.7.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.
@@ -0,0 +1,161 @@
1
+ 'use strict'
2
+
3
+ // Shared LLM client: the single implementation of chatJson, consumed by the MCP
4
+ // server (creativity engine + insight store) and by the CLI
5
+ // (`mio creativity generate` / `ferment`). Extracted from
6
+ // mio-intelligence-mcp/index.js so both entry points call the same model with
7
+ // the same configuration, and so "is an LLM configured?" has one answer.
8
+ //
9
+ // Configuration resolution order (highest first):
10
+ // 1. Environment variables
11
+ // LLM_API_URL OpenAI-compatible endpoint (default: opencode zen)
12
+ // LLM_KEY bearer token; empty means unauthenticated (common for
13
+ // a local Ollama / llama.cpp server)
14
+ // LLM_CHAT_MODEL model id, falls back to LLM_MODEL
15
+ // 2. config.json under MIO_HOME (written by `mio config llm`) — the `llm`
16
+ // object's apiUrl / apiKey / model keys
17
+ // 3. Built-in defaults
18
+ //
19
+ // Environment wins so a shell-level override can always beat a stored config
20
+ // without editing files. The config file is read lazily and cached, so a long
21
+ // running MCP process does not re-stat it on every call.
22
+
23
+ const fs = require('fs')
24
+ const os = require('os')
25
+ const path = require('path')
26
+
27
+ const DEFAULT_API_URL = 'https://opencode.ai/zen/go/v1/chat/completions'
28
+ const DEFAULT_MODEL = 'deepseek-v4-flash'
29
+
30
+ // Resolve the directory holding config.json. The CLI and the MCP server use
31
+ // different data dirs by design (MIO_HOME vs MIO_DATA_DIR||cwd/.mio-intelligence),
32
+ // so we honour whichever is set and fall back to the CLI default. No third
33
+ // convention is introduced here on purpose.
34
+ function homeDir() {
35
+ return (
36
+ process.env.MIO_HOME ||
37
+ process.env.MIO_DATA_DIR ||
38
+ path.join(os.homedir(), '.mio-intelligence')
39
+ )
40
+ }
41
+
42
+ let cachedFileConfig
43
+ let cachedFor = null
44
+
45
+ // Read the `llm` block from config.json. Any failure (missing file, bad JSON,
46
+ // wrong shape) degrades to {} — configuration must never crash a caller.
47
+ function fileConfig() {
48
+ const target = homeDir()
49
+ if (cachedFor === target) return cachedFileConfig
50
+ cachedFor = target
51
+ cachedFileConfig = {}
52
+ try {
53
+ const raw = fs.readFileSync(path.join(target, 'config.json'), 'utf8')
54
+ const parsed = JSON.parse(raw)
55
+ const llm = parsed && typeof parsed.llm === 'object' && parsed.llm !== null ? parsed.llm : {}
56
+ const pick = (v) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined)
57
+ cachedFileConfig = {
58
+ apiUrl: pick(llm.apiUrl),
59
+ apiKey: pick(llm.apiKey),
60
+ model: pick(llm.model),
61
+ }
62
+ } catch (_) {
63
+ cachedFileConfig = {}
64
+ }
65
+ return cachedFileConfig
66
+ }
67
+
68
+ // Exposed so writers (`mio config llm`) can drop the cache after editing the
69
+ // file, and so tests can reset between cases.
70
+ function resetLlmConfigCache() {
71
+ cachedFor = null
72
+ cachedFileConfig = {}
73
+ }
74
+
75
+ function llmConfig() {
76
+ const file = fileConfig()
77
+ return {
78
+ apiUrl: process.env.LLM_API_URL || file.apiUrl || DEFAULT_API_URL,
79
+ apiKey: process.env.LLM_KEY || file.apiKey || '',
80
+ model: process.env.LLM_CHAT_MODEL || process.env.LLM_MODEL || file.model || DEFAULT_MODEL,
81
+ }
82
+ }
83
+
84
+ // True when the user has pointed us somewhere other than the default hosted
85
+ // endpoint, or supplied a key. Used to warn (not block) before spending a call.
86
+ // "Configured" counts either source: an env var or a stored config.json entry.
87
+ function isLlmConfigured() {
88
+ const file = fileConfig()
89
+ return Boolean(
90
+ process.env.LLM_API_URL ||
91
+ process.env.LLM_KEY ||
92
+ file.apiUrl ||
93
+ file.apiKey ||
94
+ file.model
95
+ )
96
+ }
97
+
98
+ // Which source each field came from, for `mio config show` to display honestly.
99
+ // Reporting a value without its origin is how users end up editing config.json
100
+ // and wondering why nothing changed (an env var was shadowing them).
101
+ function llmConfigSources() {
102
+ const file = fileConfig()
103
+ return {
104
+ apiUrl: process.env.LLM_API_URL ? 'env' : file.apiUrl ? 'config' : 'default',
105
+ apiKey: process.env.LLM_KEY ? 'env' : file.apiKey ? 'config' : 'unset',
106
+ model: process.env.LLM_CHAT_MODEL
107
+ ? 'env'
108
+ : process.env.LLM_MODEL
109
+ ? 'env'
110
+ : file.model
111
+ ? 'config'
112
+ : 'default',
113
+ }
114
+ }
115
+
116
+ async function chatJson(userText, opts = {}) {
117
+ const { apiUrl, apiKey, model } = llmConfig()
118
+ const system = opts.system || 'You are a creative AI assistant. Output JSON.'
119
+ const temperature = opts.temperature ?? 0.3
120
+
121
+ try {
122
+ const resp = await fetch(apiUrl, {
123
+ method: 'POST',
124
+ headers: {
125
+ 'Content-Type': 'application/json',
126
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
127
+ },
128
+ body: JSON.stringify({
129
+ model,
130
+ temperature,
131
+ messages: [
132
+ { role: 'system', content: system },
133
+ { role: 'user', content: userText },
134
+ ],
135
+ response_format: { type: 'json_object' },
136
+ }),
137
+ signal: AbortSignal.timeout(opts.timeoutMs || 30000),
138
+ })
139
+
140
+ if (!resp.ok) return { error: `HTTP ${resp.status}` }
141
+ const json = await resp.json()
142
+ const text = json.choices?.[0]?.message?.content || ''
143
+ try {
144
+ return { data: JSON.parse(text) }
145
+ } catch {
146
+ return { data: text }
147
+ }
148
+ } catch (err) {
149
+ return { error: String(err) }
150
+ }
151
+ }
152
+
153
+ module.exports = {
154
+ chatJson,
155
+ llmConfig,
156
+ isLlmConfigured,
157
+ llmConfigSources,
158
+ resetLlmConfigCache,
159
+ DEFAULT_API_URL,
160
+ DEFAULT_MODEL,
161
+ }