mio-agent-runtime 0.13.1 → 0.13.3

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/README.md CHANGED
@@ -40,7 +40,7 @@ mio evolution authority plan Preview a gated authority switch plan (--readi
40
40
  mio evolution migration plan Preview state migration diffs (--legacy-records/--modular-records JSON; required)
41
41
  mio evolution cutover apply --dry-run Dry-run a cutover plan (--plan JSON and --dry-run are both required)
42
42
  mio observe Watch WorkBuddy transcripts and auto-ingest task outcomes (foreground)
43
- mio observe --start|--stop|--status|--once Manage the background observer daemon
43
+ mio observe --start [--research]|--stop|--status|--once Manage the background observer daemon (--research also starts the research scheduler)
44
44
  mio recall "<query>" Search Mio memory from the terminal (--project/--scope/--kind/--tags/--limit)
45
45
  mio traces Show recent observer traces (--type/--outcome/--agent/--since/--limit/--compact)
46
46
  mio prune --days 30 Trim old traces/queries/reuse records and observe.log (--dry-run to preview; --memory needs --yes)
@@ -66,6 +66,7 @@ mio observer <view> Observer research pipeline views (research pipeline
66
66
  mio observer collect Fetch from the configured sources (--sources a,b/--keywords k1,k2/--limit N)
67
67
  mio observer ferment Run the fermentation engine (--session morning|afternoon|night)
68
68
  mio observer pipeline Run the research DAG (previews by default; --run executes, --mode neutral|analytical|creative)
69
+ mio observer serve Keep the research scheduler running (previews with --dry-run; Ctrl+C stops it)
69
70
  mio observer ingest --trace-id T --event-type E Record a trace event (--payload JSON/--outcome)
70
71
  mio observer subscribe --event-types a,b Subscribe to events (--yes to apply; previews by default)
71
72
  mio observer digest New events since the last digest (advances the cursor)
@@ -132,7 +133,7 @@ MCP 服务端在 5 大域共暴露 50 个工具:
132
133
  | `@akemi-mio/evolution-strategy` | 演化策略引擎 |
133
134
  | `@akemi-mio/evolution-safety` | 安全护栏 |
134
135
  | `@akemi-mio/evolution-scheduler` | 演化调度器 |
135
- | `@akemi-mio/observer` | ObserverService —— 多源数据采集、趋势分析、深度研究、世界模型、发酵、DAG 状态机、自演化引擎(零 npm 依赖) |
136
+ | `@akemi-mio/observer` | ObserverService —— 多源数据采集、趋势分析、深度研究、世界模型、发酵、DAG 状态机、自演化引擎(唯一运行时依赖 `undici`,只用于走代理采集) |
136
137
  | `@akemi-mio/insight` | InsightService —— 基于 LLM 的洞察生成、冲突/漂移/重复/停滞目标/摩擦检测器、在场服务、洞察打分(零 npm 依赖) |
137
138
 
138
139
  以下 `@akemi-mio` 包同样位于本 monorepo 中并独立发布,但**不是**本 CLI 的依赖——其源码从不 import 它们,因此未声明在 `package.json` 里:
@@ -499,6 +500,26 @@ mio observer trends --base-dir /path/to/.local/observer
499
500
  mio observer pipeline --mode creative --run
500
501
  ```
501
502
 
503
+ - **`mio observer serve` 让"日更引擎"真正自动跑起来**(D6)。`ObserverService.start()` 早就配好了各源的采集间隔、60 秒一次的管道 tick 与 5 秒后的首次 tick,并在"今天的 DAG 已 `COMPLETED`"时跳过——但 runtime 里**没有任何长驻进程调用过它**,这些闸门一次都没生效过,trends / research / insights 只有手动 `mio observer pipeline --run` 才会出现。现在有了宿主:
504
+
505
+ ```bash
506
+ mio observer serve # 前台常驻:采集 + 每日一次完整 DAG(Ctrl+C 停)
507
+ mio observer serve --dry-run # 只打印计划:baseDir / LLM / tick 周期 / 锁文件
508
+ mio observe --start --research # 顺带把调度器作为第二个受 pid 管理的子进程拉起
509
+ ```
510
+
511
+ - `--dry-run` 不构造服务、不建目录、不联网,因此也是文档门禁实跑的参数(`SAFE_ARGS['observer:serve']`)。
512
+ - `--research` 是**显式开关**:默认 `mio observe --start` 只启动转写观察守护,不会悄悄开始花 LLM 调用。研究子进程日志写到 `<MIO_HOME>/research.log`(用 append fd 而不是管道:没人读的管道写满 64KB 就会把调度器卡死),`mio observe --status --json` 给出它的 pid 与日志路径,`mio observe --stop` 一并停掉两者。
513
+ - 研究数据仍落在**启动它的那个工作目录**的 `<cwd>/.local/observer`(与 `mio observer status` 一致):子进程显式收到 `--base-dir`,因为转写守护 spawn 时 cwd 被钉成 `MIO_HOME`,若在守护进程里直接起调度,会多出一套谁也读不到的观察目录。
514
+ - 刻意**不提供** `mio.observer.serve` 这类 MCP 工具:每个连上的 MCP server 都会各起一个调度器,客户端一重启就多一个。
515
+ - `ObserverService` 内的 `pipelineRunning` 只防同进程并发;serve 子进程、守护与手动 `--run` 是三个进程,所以 `runPipeline` 现在还要抢 `<baseDir>/dag/pipeline.lock`(`O_EXCL` 建文件 + pid / mtime TTL,持有者已死即可接管)。锁被占时直接返回 `null`,不排队。
516
+
517
+ - **采集会走系统与环境里配置的代理**(D7)。6 个采集源共 9 处请求此前直接用全局 `fetch`,而 Node 的 fetch **不读任何代理配置**——所以浏览器能打开的页面,采集全部超时(issue #3:Windows 开着系统代理、直连不通时,weibo / rss / hackernews / douyin / bilibili / github-trending 全军覆没)。现在统一走 `@akemi-mio/observer` 的 `httpFetch()`:
518
+ - 代理按顺序取:`HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY`(大小写皆可)→ Windows 系统代理(`reg query` 读 HKCU Internet Settings 的 `ProxyEnable` / `ProxyServer` / `ProxyOverride`,进程内缓存 30 秒)。`ProxyServer` 的 `h=..;https=..` 与单值两种写法都认,**端口读实时注册表**——issue 里写的 7990 早已不是本机端口,硬编码必然过期。
519
+ - 绕过:`NO_PROXY` 与 `ProxyOverride`,支持 `*`、`<local>`、`*.zhihu.com`、`127.*`、裸域名(含子域)。
520
+ - **解析不到代理就走原生 `fetch`,与从前逐字节一致**;解析到代理时用 `undici` 的 `ProxyAgent`(CONNECT 隧道,https 与 http 目标同样处理)。代理连不上(`ECONNREFUSED` / `ENOTFOUND` / `EHOSTUNREACH`)会**回退直连**并记 `proxy_fallback_direct`,避免"系统代理开着但客户端已关"把原本能通的直连也搞坏。
521
+ - `ObserverLlmService` 不在这条通路上(issue 只讲采集失败)。
522
+
502
523
  - **观察者的 LLM 现在与 creativity / insight 共用同一份配置**。此前 `ObserverLlmService` 把 Ollama 地址与模型写死(`localhost:11434` / `qwen2.5:7b`,只认 `OBSERVER_*` 环境变量),完全无视 `mio config llm` —— 于是 `mio config llm` 配好的 deepseek 只对 server 侧生效,观察管道仍然空转。现在 `observer-store.js` 通过共享的 `server/llm-client.js` 解析配置并注入:用户配置过(`config.json` 的 `llm` 或 `LLM_*` 环境变量)就用它,否则保持 `@akemi-mio/observer` 自己的 `LLM_*` / `OBSERVER_*` / 本地 Ollama 回退,不会把既有本地 Ollama 用户静默改道到托管默认值。端点按 URL 形态自动选择传输:含 `/chat/completions` 走 OpenAI 兼容协议,否则按 Ollama 处理。
503
524
  - **`mio observer ingest` 记录任意 trace 事件**(`tool_call` / `error` / `retry` / `task_outcome`):
504
525
 
package/bin/mio.js CHANGED
@@ -27,7 +27,7 @@ const { CreativityEngine } = require('../server/creativity-engine.js')
27
27
  const { createAgentStore } = require('../server/agent-store.js')
28
28
  const { createEvaluationStore } = require('../server/evaluation-store.js')
29
29
  const { createInsightStore } = require('../server/insight-store.js')
30
- const { createObserverStore } = require('../server/observer-store.js')
30
+ const { createObserverStore, isObserverAvailable } = require('../server/observer-store.js')
31
31
  const { loadPhase0, renderPhase0Markdown } = require('../server/mio-intelligence-mcp/phase0.js')
32
32
  const { listHostCapabilities } = require('../server/host-capabilities.js')
33
33
  const { createTaskStore } = require('../server/task-store.js')
@@ -1533,6 +1533,10 @@ function observerUsage(write = console.log) {
1533
1533
  mio observer pipeline Run the full research DAG (collect → trend → research →
1534
1534
  insights → world model). Previews by default; --run
1535
1535
  executes it (network + LLM). --mode neutral|analytical|creative
1536
+ mio observer serve Keep the research scheduler running in this terminal:
1537
+ collectors on their intervals + one pipeline run per
1538
+ day. Previews with --dry-run (that is what runs it
1539
+ the README/doc gate); Ctrl+C stops it.
1536
1540
 
1537
1541
  Options:
1538
1542
  --base-dir DIR Observer data directory (default: <cwd>/.local/observer)
@@ -1884,6 +1888,9 @@ function printObserverPipeline(result) {
1884
1888
  }
1885
1889
  if (!result.completed) {
1886
1890
  console.log(`Observer pipeline did not complete: ${result.reason || 'unknown reason'}`)
1891
+ // Show what the run actually produced, so a failing exit code is diagnosable
1892
+ // instead of a bare "unknown reason".
1893
+ console.log(` sections=${result.sections || 0} topic=${result.topic || 'none'} insight=${result.insightId || 'none'}`)
1887
1894
  process.exitCode = 1
1888
1895
  return
1889
1896
  }
@@ -1915,6 +1922,66 @@ async function observerPipelineCommand(args, useJson) {
1915
1922
  printObserverPipeline(result)
1916
1923
  }
1917
1924
 
1925
+ function printObserverServe(result) {
1926
+ if (result.dryRun) {
1927
+ console.log(`Observer research scheduler preview (base-dir=${result.baseDir})`)
1928
+ if (result.llm) {
1929
+ const key = result.llm.hasApiKey ? 'key set' : 'no key'
1930
+ console.log(` LLM: ${result.llm.provider} ${result.llm.model} (${key})`)
1931
+ console.log(` ${result.llm.apiUrl}`)
1932
+ } else {
1933
+ console.log(' LLM: unknown (observer package did not expose ObserverLlmService)')
1934
+ }
1935
+ if (result.schedule) {
1936
+ console.log(` pipeline: first tick ${result.schedule.pipelineFirstTickMs}ms, then every ${result.schedule.pipelineTickMs}ms`)
1937
+ console.log(` gate: ${result.schedule.pipelineGate}`)
1938
+ console.log(` lock: ${result.schedule.lockFile}`)
1939
+ }
1940
+ console.log('\nPreview only. Re-run without --dry-run to keep the scheduler running (Ctrl+C stops it).')
1941
+ return
1942
+ }
1943
+ console.log(`Observer research scheduler running (base-dir=${result.baseDir})`)
1944
+ console.log(` ${result.schedule ? result.schedule.collectors : 'collectors started'}`)
1945
+ console.log(` pipeline: first tick ${result.schedule ? result.schedule.pipelineFirstTickMs : 5000}ms, then every ${result.schedule ? result.schedule.pipelineTickMs : 60000}ms`)
1946
+ console.log(' Stops with Ctrl+C (or `mio observe --stop` when started that way).')
1947
+ }
1948
+
1949
+ // D6: `mio observer serve` is the long-lived host for ObserverService.start().
1950
+ // Without it the scheduler inside the service had no caller, so tickPipeline's
1951
+ // daily gate never fired and trends/research/insights only appeared if someone
1952
+ // ran `mio observer pipeline --run` by hand. It stays in the foreground on
1953
+ // purpose -- a scheduler is a process, not a request -- and `--dry-run` exists
1954
+ // both as a preview and as the probe `check:cli-docs` runs, so the documented
1955
+ // command can never hang the gate.
1956
+ async function observerServeCommand(args, useJson) {
1957
+ const flags = args.slice(2)
1958
+ const base = { baseDir: optionValue(flags, '--base-dir') }
1959
+ const dryRun = flagPresent(flags, '--dry-run')
1960
+ let result
1961
+ try {
1962
+ result = await cliObserverStore().serve({ ...base, dryRun })
1963
+ } catch (error) {
1964
+ console.error(error.message || error)
1965
+ process.exitCode = 1
1966
+ return
1967
+ }
1968
+ if (useJson && result.dryRun) return jsonOrText(result, true)
1969
+ if (result.dryRun) {
1970
+ printObserverServe(result)
1971
+ return
1972
+ }
1973
+ if (useJson) console.log(JSON.stringify({ serving: true, baseDir: result.baseDir, schedule: result.schedule }))
1974
+ else printObserverServe(result)
1975
+ const stop = typeof result.stop === 'function' ? result.stop : () => {}
1976
+ const shutdown = (signal) => {
1977
+ stop()
1978
+ console.log('Observer research scheduler stopped (' + signal + ')')
1979
+ process.exit(0)
1980
+ }
1981
+ process.once('SIGINT', () => shutdown('SIGINT'))
1982
+ process.once('SIGTERM', () => shutdown('SIGTERM'))
1983
+ }
1984
+
1918
1985
  function observerCommand(args, useJson) {
1919
1986
  const sub = args[1]
1920
1987
  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
@@ -1930,6 +1997,7 @@ function observerCommand(args, useJson) {
1930
1997
  if (sub === 'collect') return observerCollectCommand(args, useJson)
1931
1998
  if (sub === 'ferment') return observerFermentCommand(args, useJson)
1932
1999
  if (sub === 'pipeline') return observerPipelineCommand(args, useJson)
2000
+ if (sub === 'serve') return observerServeCommand(args, useJson)
1933
2001
 
1934
2002
  if (!['status', 'world-model', 'trends', 'research', 'insights', 'essays', 'dag'].includes(sub)) {
1935
2003
  console.error(`Unknown observer subcommand: ${sub}`)
@@ -3095,26 +3163,67 @@ function mcpCommand() {
3095
3163
  require(SERVER_SCRIPT)
3096
3164
  }
3097
3165
 
3098
- function observeCommand(args) {
3166
+ // `--research` is opt-in: it adds a second child (mio observer serve) that owns
3167
+ // the collector intervals and the daily pipeline tick. It is deliberately not
3168
+ // on by default -- starting the transcript watcher should not silently start
3169
+ // spending LLM calls in the background (D6).
3170
+ function startResearchScheduler(cwd) {
3171
+ if (!isObserverAvailable()) {
3172
+ return { running: false, started: false, pid: null, skipped: '@akemi-mio/observer not installed' }
3173
+ }
3174
+ return observer.startResearchBackground(MIO_HOME, { cwd })
3175
+ }
3176
+
3177
+ function observeCommand(args, jsonFlag = false) {
3099
3178
  const flags = args.slice(1)
3100
- const useJson = flags.includes('--json')
3179
+ // main() already strips --json from argv and passes it as the second
3180
+ // argument; the local check is kept for direct callers. Without the
3181
+ // parameter `mio observe --json --status` silently printed text.
3182
+ const useJson = jsonFlag || flags.includes('--json')
3183
+ const research = flags.includes('--research')
3101
3184
  if (flags.includes('--start')) {
3102
3185
  const result = observer.startBackground(MIO_HOME)
3103
- if (useJson) return jsonOrText(result, true)
3186
+ const researchResult = research ? startResearchScheduler(process.cwd()) : null
3187
+ if (useJson) {
3188
+ return jsonOrText({ ...result, research: researchResult || { running: null, started: false, pid: null } }, true)
3189
+ }
3104
3190
  console.log(result.started ? 'Observer started.' : 'Observer already running.')
3191
+ if (researchResult) {
3192
+ if (researchResult.skipped) console.log('Research scheduler skipped: ' + researchResult.skipped)
3193
+ else if (researchResult.started) console.log('Research scheduler started (pid ' + researchResult.pid + ', base-dir ' + researchResult.baseDir + ')')
3194
+ else console.log('Research scheduler already running (pid ' + researchResult.pid + ')')
3195
+ }
3105
3196
  return
3106
3197
  }
3107
3198
  if (flags.includes('--stop')) {
3108
3199
  const result = observer.stopBackground(MIO_HOME)
3109
- if (useJson) return jsonOrText(result, true)
3200
+ const researchResult = observer.stopResearchBackground(MIO_HOME)
3201
+ if (useJson) {
3202
+ return jsonOrText(
3203
+ { ...result, research: { running: false, stopped: researchResult.stopped, pid: researchResult.pid } },
3204
+ true
3205
+ )
3206
+ }
3110
3207
  console.log(result.stopped ? 'Observer stopped.' : 'Observer was not running.')
3208
+ console.log(researchResult.stopped ? 'Research scheduler stopped.' : 'Research scheduler was not running.')
3111
3209
  return
3112
3210
  }
3113
3211
  if (flags.includes('--status')) {
3114
3212
  const running = observer.isRunning(MIO_HOME)
3115
3213
  const pid = observer.readPid(MIO_HOME)
3116
- if (useJson) return jsonOrText({ running, pid }, true)
3214
+ const researchRunning = observer.isResearchRunning(MIO_HOME)
3215
+ const researchPid = observer.readResearchPid(MIO_HOME)
3216
+ const researchInfo = { running: researchRunning, pid: researchPid, logFile: observer.researchLogFile(MIO_HOME) }
3217
+ if (useJson) return jsonOrText({ running, pid, research: researchInfo }, true)
3117
3218
  console.log(running ? 'Observer: running (pid ' + pid + ')' : 'Observer: not running')
3219
+ console.log(
3220
+ researchRunning ? 'Research scheduler: running (pid ' + researchPid + ')' : 'Research scheduler: not running'
3221
+ )
3222
+ return
3223
+ }
3224
+ if (flags.includes('--research') && !flags.includes('--start')) {
3225
+ console.error('--research only applies to --start; run `mio observer serve` to run the scheduler in this terminal.')
3226
+ process.exitCode = 1
3118
3227
  return
3119
3228
  }
3120
3229
  if (flags.includes('--once')) {
@@ -3165,7 +3274,7 @@ Usage:
3165
3274
  mio evolution migration plan Preview state migration diffs (--legacy-records/--modular-records JSON; required)
3166
3275
  mio evolution cutover apply --dry-run Dry-run a cutover plan (--plan JSON and --dry-run are both required)
3167
3276
  mio observe Watch WorkBuddy transcripts and auto-ingest task outcomes
3168
- mio observe --start|--stop|--status|--once Manage the background observer daemon
3277
+ mio observe --start [--research]|--stop|--status|--once Manage the background observer daemon
3169
3278
  mio recall "<query>" Search Mio memory from the terminal (same ranking as mio.memory.query)
3170
3279
  mio traces Show recent observer traces (--type/--outcome/--agent/--since/--limit/--compact)
3171
3280
  mio remember "<content>" Write a memory record from the terminal (same schema as mio.memory.record)
@@ -3194,6 +3303,7 @@ Usage:
3194
3303
  mio observer collect Fetch from the configured sources (--sources/--keywords/--limit)
3195
3304
  mio observer ferment Run the fermentation engine (--session morning|afternoon|night)
3196
3305
  mio observer pipeline Run the research DAG (previews; --run to execute, --mode neutral|analytical|creative)
3306
+ mio observer serve Keep the research scheduler running (previews with --dry-run; Ctrl+C stops it)
3197
3307
  mio phase0 report Show the Phase 0 validation report (--project X, --format markdown)
3198
3308
  mio host capabilities Show what each host supports and whether it is installed
3199
3309
  mio task route "<task>" Which verified experiences apply to this task (--project/--scope/--limit)
@@ -3229,7 +3339,7 @@ async function main() {
3229
3339
  case 'agents':
3230
3340
  return agentsCommand(args, useJson)
3231
3341
  case 'observe':
3232
- return observeCommand(args)
3342
+ return observeCommand(args, useJson)
3233
3343
  case 'recall':
3234
3344
  return recallCommand(args, useJson)
3235
3345
  case 'traces':
@@ -1695,9 +1695,35 @@ function pidFile(home) {
1695
1695
  return path.join(home, 'observe.pid')
1696
1696
  }
1697
1697
 
1698
+ function researchPidFile(home) {
1699
+ return path.join(home, 'research.pid')
1700
+ }
1701
+
1702
+ function researchLogFile(home) {
1703
+ return path.join(home, 'research.log')
1704
+ }
1705
+
1706
+ function pidAlive(pid) {
1707
+ if (!Number.isInteger(pid) || pid <= 0) return false
1708
+ try {
1709
+ process.kill(pid, 0)
1710
+ return true
1711
+ } catch (_) {
1712
+ return false
1713
+ }
1714
+ }
1715
+
1698
1716
  function readPid(home) {
1717
+ return readPidFile(pidFile(home))
1718
+ }
1719
+
1720
+ function readResearchPid(home) {
1721
+ return readPidFile(researchPidFile(home))
1722
+ }
1723
+
1724
+ function readPidFile(file) {
1699
1725
  try {
1700
- const pid = Number(fs.readFileSync(pidFile(home), 'utf8').trim())
1726
+ const pid = Number(fs.readFileSync(file, 'utf8').trim())
1701
1727
  return Number.isInteger(pid) && pid > 0 ? pid : null
1702
1728
  } catch (_) {
1703
1729
  return null
@@ -1705,14 +1731,11 @@ function readPid(home) {
1705
1731
  }
1706
1732
 
1707
1733
  function isRunning(home) {
1708
- const pid = readPid(home)
1709
- if (!pid) return false
1710
- try {
1711
- process.kill(pid, 0)
1712
- return true
1713
- } catch (_) {
1714
- return false
1715
- }
1734
+ return pidAlive(readPid(home))
1735
+ }
1736
+
1737
+ function isResearchRunning(home) {
1738
+ return pidAlive(readResearchPid(home))
1716
1739
  }
1717
1740
 
1718
1741
  function startBackground(home) {
@@ -1745,6 +1768,69 @@ function stopBackground(home) {
1745
1768
  return { running: false, stopped: Boolean(pid), pid: pid || null }
1746
1769
  }
1747
1770
 
1771
+ // ---------------------------------------------------------------------------
1772
+ // research scheduler daemon (`mio observe --start --research`)
1773
+ //
1774
+ // D6: ObserverService's own scheduler (collector intervals + the daily
1775
+ // pipeline tick) needed a process to live in. The transcript daemon could have
1776
+ // hosted it in-process, but that daemon is spawned with cwd: home, so its
1777
+ // `<cwd>/.local/observer` would resolve to MIO_HOME -- a second observation
1778
+ // directory that `mio observer status` in the project would never see. Running
1779
+ // `mio observer serve` as its own child with the *launcher's* cwd keeps one
1780
+ // data directory per working directory, exactly like every other observer
1781
+ // command, and keeps a crash in either half from taking down the other.
1782
+ //
1783
+ // spawn/kill are injectable so tests never signal a real pid.
1784
+ // ---------------------------------------------------------------------------
1785
+
1786
+ function startResearchBackground(home, opts = {}) {
1787
+ const cwd = opts.cwd || process.cwd()
1788
+ const spawnImpl = opts.spawn || spawn
1789
+ if (isResearchRunning(home)) {
1790
+ return { running: true, started: false, pid: readResearchPid(home), baseDir: path.join(cwd, '.local', 'observer') }
1791
+ }
1792
+ const script = path.join(__dirname, '..', 'bin', 'mio.js')
1793
+ const baseDir = path.join(cwd, '.local', 'observer')
1794
+ // The scheduler logs to stdout, so a detached child with stdio:'ignore' would
1795
+ // be undiagnosable ("is it running? did it collect?"). Handing it an append
1796
+ // fd instead of a pipe is deliberate: a pipe nobody reads fills up after
1797
+ // ~64KB and the scheduler would block on its own log.
1798
+ fs.mkdirSync(home, { recursive: true })
1799
+ const logFd = fs.openSync(researchLogFile(home), 'a')
1800
+ let child
1801
+ try {
1802
+ child = spawnImpl(
1803
+ process.execPath,
1804
+ [script, 'observer', 'serve', '--base-dir', baseDir],
1805
+ {
1806
+ detached: true,
1807
+ stdio: ['ignore', logFd, logFd],
1808
+ windowsHide: true,
1809
+ cwd,
1810
+ }
1811
+ )
1812
+ } finally {
1813
+ fs.closeSync(logFd)
1814
+ }
1815
+ if (child.unref) child.unref()
1816
+ fs.writeFileSync(researchPidFile(home), String(child.pid), 'utf8')
1817
+ return { running: true, started: true, pid: child.pid, baseDir, logFile: researchLogFile(home) }
1818
+ }
1819
+
1820
+ function stopResearchBackground(home, killImpl) {
1821
+ const kill = killImpl || process.kill
1822
+ const pid = readResearchPid(home)
1823
+ if (pid) {
1824
+ try {
1825
+ kill(pid)
1826
+ } catch (_) {}
1827
+ try {
1828
+ fs.unlinkSync(researchPidFile(home))
1829
+ } catch (_) {}
1830
+ }
1831
+ return { running: false, stopped: Boolean(pid), pid: pid || null }
1832
+ }
1833
+
1748
1834
  module.exports = {
1749
1835
  INTERVAL_MS,
1750
1836
  homeDir,
@@ -1770,6 +1856,11 @@ module.exports = {
1770
1856
  stopBackground,
1771
1857
  isRunning,
1772
1858
  readPid,
1859
+ startResearchBackground,
1860
+ stopResearchBackground,
1861
+ isResearchRunning,
1862
+ readResearchPid,
1863
+ researchLogFile,
1773
1864
  // exported for tests: the numeric-segment guard here was silently dead for
1774
1865
  // several releases because nothing exercised it (see __tests__/observer-project.test.js)
1775
1866
  projectFromDirName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mio-agent-runtime",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Mio Agent Runtime installer and MCP intelligence layer",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -33,7 +33,7 @@
33
33
  "@akemi-mio/evolution-strategy": "^0.1.0",
34
34
  "@akemi-mio/evolution-safety": "^0.1.0",
35
35
  "@akemi-mio/evolution-scheduler": "^0.1.0",
36
- "@akemi-mio/observer": "^0.1.1",
36
+ "@akemi-mio/observer": "^0.1.3",
37
37
  "@akemi-mio/insight": "^0.1.0"
38
38
  },
39
39
  "main": "index.js",
@@ -306,12 +306,22 @@ function createObserverStore(options = {}) {
306
306
  }
307
307
  }
308
308
  const payload = envelope.payload || {}
309
+ // dagState.state is COMPLETED on @akemi-mio/observer >= 0.1.2, which marks the
310
+ // dag COMPLETED *before* publishInsight snapshots it into the envelope.
311
+ // Older producers published while the dag still said STORED even though the
312
+ // run had in fact finished, and a strict COMPLETED check turned every success
313
+ // into completed:false (D5). This branch only exists when publishing
314
+ // succeeded, so STORED counts as completed; anything else is a real failure
315
+ // and must carry a reason, otherwise the CLI can only print "unknown reason".
316
+ const state = envelope.dagState ? envelope.dagState.state : null
317
+ const completed = state === 'COMPLETED' || state === 'STORED'
309
318
  return {
310
319
  dryRun: false,
311
320
  baseDir,
312
321
  mode,
313
322
  ran: true,
314
- completed: envelope.dagState && envelope.dagState.state === 'COMPLETED',
323
+ completed,
324
+ ...(completed ? {} : { reason: `unexpected dag state: ${state || 'missing'} (expected COMPLETED)` }),
315
325
  type: envelope.type,
316
326
  taskId: envelope.dagState ? envelope.dagState.taskId : null,
317
327
  topic: payload.topic || null,
@@ -322,6 +332,64 @@ function createObserverStore(options = {}) {
322
332
  }
323
333
  }
324
334
 
335
+ // D6: the scheduler host. ObserverService.start() wires the per-collector
336
+ // intervals, a 60s pipeline tick and a 5s first tick, and tickPipeline skips
337
+ // while today's DAG is COMPLETED -- i.e. the daily research engine already
338
+ // existed, it just had no long-lived caller anywhere in mio-agent-runtime.
339
+ // This is that caller.
340
+ //
341
+ // Deliberately NOT exposed as an MCP tool: every MCP server that connects
342
+ // would start its own scheduler against the same dag file, and a client
343
+ // restart would leave a second one running. The cross-process PipelineLock
344
+ // keeps runs from interleaving, but a scheduler is a process-lifetime
345
+ // concern, so it belongs to `mio observer serve` and `mio observe --research`
346
+ // (both single pid-managed processes), not to a tool call.
347
+ //
348
+ // Like `pipeline`, the dry run never constructs the service: the constructor
349
+ // mkdir's the dag directory and start() begins fetching, and this path is
350
+ // what `check:cli-docs` runs.
351
+ async function serve(args = {}) {
352
+ if (!ObserverService) throw new Error('@akemi-mio/observer not installed')
353
+ const baseDir = baseDirOf(args)
354
+ const llmConfig = observerLlmConfig()
355
+ const schedule = {
356
+ collectors: 'every source collects on start, then on its own interval (30m weibo / 1h rss,hn,douyin,bilibili / 4h github-trending)',
357
+ pipelineTickMs: 60000,
358
+ pipelineFirstTickMs: 5000,
359
+ pipelineGate: "runs while today's DAG is not COMPLETED -- one full run per day, retried until it completes",
360
+ lockFile: path.join(baseDir, 'dag', 'pipeline.lock'),
361
+ }
362
+ let llm = null
363
+ if (ObserverLlmService) {
364
+ try {
365
+ const svc = new ObserverLlmService(llmConfig)
366
+ if (svc && typeof svc.getInfo === 'function') llm = svc.getInfo()
367
+ } catch (_) {
368
+ llm = null
369
+ }
370
+ }
371
+ if (args.dryRun) {
372
+ return {
373
+ dryRun: true,
374
+ baseDir,
375
+ observerAvailable: true,
376
+ llm,
377
+ schedule,
378
+ hint: 'Re-run without --dry-run to keep the scheduler in the foreground (Ctrl+C stops it).',
379
+ }
380
+ }
381
+ const service = new ObserverService(baseDir, llmConfig)
382
+ await service.start()
383
+ return {
384
+ dryRun: false,
385
+ serving: true,
386
+ baseDir,
387
+ llm,
388
+ schedule,
389
+ stop: () => service.stop(),
390
+ }
391
+ }
392
+
325
393
  return {
326
394
  baseDir: defaultBaseDir,
327
395
  status,
@@ -334,6 +402,7 @@ function createObserverStore(options = {}) {
334
402
  collect,
335
403
  ferment,
336
404
  pipeline,
405
+ serve,
337
406
  }
338
407
  }
339
408