mio-agent-runtime 0.6.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mio-agent-runtime",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Mio Agent Runtime installer and MCP intelligence layer",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -6,27 +6,111 @@
6
6
  // mio-intelligence-mcp/index.js so both entry points call the same model with
7
7
  // the same configuration, and so "is an LLM configured?" has one answer.
8
8
  //
9
- // Configuration is entirely environment-based:
10
- // LLM_API_URL OpenAI-compatible endpoint (default: opencode zen)
11
- // LLM_KEY bearer token; empty means unauthenticated (common for a
12
- // local Ollama / llama.cpp server)
13
- // LLM_CHAT_MODEL model id, falls back to LLM_MODEL
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')
14
26
 
15
27
  const DEFAULT_API_URL = 'https://opencode.ai/zen/go/v1/chat/completions'
16
28
  const DEFAULT_MODEL = 'deepseek-v4-flash'
17
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
+
18
75
  function llmConfig() {
76
+ const file = fileConfig()
19
77
  return {
20
- apiUrl: process.env.LLM_API_URL || DEFAULT_API_URL,
21
- apiKey: process.env.LLM_KEY || '',
22
- model: process.env.LLM_CHAT_MODEL || process.env.LLM_MODEL || DEFAULT_MODEL,
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,
23
81
  }
24
82
  }
25
83
 
26
84
  // True when the user has pointed us somewhere other than the default hosted
27
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.
28
87
  function isLlmConfigured() {
29
- return Boolean(process.env.LLM_API_URL || process.env.LLM_KEY)
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
+ }
30
114
  }
31
115
 
32
116
  async function chatJson(userText, opts = {}) {
@@ -66,4 +150,12 @@ async function chatJson(userText, opts = {}) {
66
150
  }
67
151
  }
68
152
 
69
- module.exports = { chatJson, llmConfig, isLlmConfigured }
153
+ module.exports = {
154
+ chatJson,
155
+ llmConfig,
156
+ isLlmConfigured,
157
+ llmConfigSources,
158
+ resetLlmConfigCache,
159
+ DEFAULT_API_URL,
160
+ DEFAULT_MODEL,
161
+ }
@@ -913,6 +913,9 @@ function observerStatus(args = {}) {
913
913
  return observerStore.status(args)
914
914
  }
915
915
 
916
+ // Both return promises (the upstream collector and fermentation engine are
917
+ // async). callTool is async, so the promise is awaited before the result is
918
+ // serialized -- but nothing here may treat them as synchronous values.
916
919
  function observerCollect(args = {}) {
917
920
  return observerStore.collect(args)
918
921
  }
@@ -161,19 +161,28 @@ function createObserverStore(options = {}) {
161
161
  return { summaries, summaryCount: summaries.reduce((a, s) => a + s.summaries.length, 0) }
162
162
  }
163
163
 
164
- function collect(args = {}) {
164
+ // ObserverService.collectBySource and FermentationEngine.ferment are both
165
+ // async. They must be awaited here: a Promise is `typeof 'object'` with no
166
+ // own enumerable keys, so the previous synchronous version of collect()
167
+ // happily "iterated" one and pushed nothing -- every call reported
168
+ // `collected: 0` no matter how much the collectors actually returned.
169
+ async function collect(args = {}) {
165
170
  if (!ObserverService) throw new Error('@akemi-mio/observer not installed')
166
171
  const service = new ObserverService({ baseDir: baseDirOf(args), collectorConfig: {} })
167
172
  const sources = args.sources || ['bilibili', 'hackernews', 'github', 'douyin', 'rss']
168
173
  const allObs = []
169
174
  for (const src of sources) {
170
175
  try {
171
- const collectFn = service.collectBySource ? service.collectBySource : service.collect
172
- if (collectFn) {
173
- const result = collectFn.call(service, [src], args.keywords || [], args.limit || 20)
174
- if (Array.isArray(result)) allObs.push(...result)
175
- else if (result && typeof result === 'object') {
176
- for (const [, items] of Object.entries(result)) allObs.push(...items)
176
+ const collectFn = service.collectBySource || service.collect
177
+ if (!collectFn) {
178
+ throw new Error('ObserverService exposes neither collectBySource nor collect')
179
+ }
180
+ const result = await collectFn.call(service, [src], args.keywords || [], args.limit || 20)
181
+ if (Array.isArray(result)) allObs.push(...result)
182
+ else if (result && typeof result === 'object') {
183
+ // Record<sourceName, Observation[]>: keep every list, in key order.
184
+ for (const items of Object.values(result)) {
185
+ if (Array.isArray(items)) allObs.push(...items)
177
186
  }
178
187
  }
179
188
  } catch (e) {
@@ -183,13 +192,13 @@ function createObserverStore(options = {}) {
183
192
  return { collected: allObs.length, observations: allObs }
184
193
  }
185
194
 
186
- function ferment(args = {}) {
195
+ async function ferment(args = {}) {
187
196
  if (!ObserverService) throw new Error('@akemi-mio/observer not installed')
188
197
  const service = new ObserverService({ baseDir: baseDirOf(args), collectorConfig: {} })
189
198
  const session = args.session || 'afternoon'
190
199
  try {
191
200
  const fermentation = service.getFermentation()
192
- if (fermentation && fermentation.ferment) return fermentation.ferment(session)
201
+ if (fermentation && fermentation.ferment) return await fermentation.ferment(session)
193
202
  return { status: 'fermentation engine available but no ferment method' }
194
203
  } catch (e) {
195
204
  return { error: e.message }