mio-agent-runtime 0.13.2 → 0.13.4

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
 
@@ -46,12 +46,12 @@ const MEMORY_BLOCK = `${MEMORY_BEGIN}
46
46
 
47
47
  Mio MCP tools are available. Treat these as mandatory runtime rules for every workspace.
48
48
 
49
- - Before designing or implementing a solution, or when unsure about prior decisions, call \`mio-intelligence_mio.memory.query\` first. Omit \`project\` unless a specific project is required; Mio derives it from the current Git repository.
50
- - After a decision affecting architecture, deployment, dependencies, or long-term behavior, call \`mio-intelligence_mio.memory.record\` with \`kind\`: \`decision\`, \`context\`, \`problem\`, or \`note\`.
51
- - When a tool call fails, retries, or returns an error, call \`mio-intelligence_mio.observer.ingest\` with a stable \`trace_id\`, relevant \`event_type\`, \`payload\`, and \`outcome\`.
52
- - Before a high-risk operation such as deletion, migration, credential change, or production change, call \`mio-intelligence_mio.policy.check\` and follow its suggestion when available.
53
- - At the end of every task, before producing the final answer, call \`mio-intelligence_mio.observer.ingest\` with \`event_type\` = \`task_outcome\`, a stable \`trace_id\`, \`outcome\` = \`success\` / \`failure\` / \`aborted\`, and a brief non-sensitive \`payload\`. If Mio is unavailable, do not block the final answer.
54
- - When you reuse a prior decision or experience returned by \`mio-intelligence_mio.memory.query\` and it changes your approach or outcome, call \`mio-intelligence_mio.experience.reuse\` with \`sourceAgent\`, \`targetAgent\`, \`experienceId\`, \`reuse\`, \`behaviorChanged\`, and \`outcomeImproved\`.
49
+ - Before designing or implementing a solution, or when unsure about prior decisions, call \`mcp__mio-intelligence__mio.memory.query\` first. Omit \`project\` unless a specific project is required; Mio derives it from the current Git repository.
50
+ - After a decision affecting architecture, deployment, dependencies, or long-term behavior, call \`mcp__mio-intelligence__mio.memory.record\` with \`kind\`: \`decision\`, \`context\`, \`problem\`, or \`note\`.
51
+ - When a tool call fails, retries, or returns an error, call \`mcp__mio-intelligence__mio.observer.ingest\` with a stable \`trace_id\`, relevant \`event_type\`, \`payload\`, and \`outcome\`.
52
+ - Before a high-risk operation such as deletion, migration, credential change, or production change, call \`mcp__mio-intelligence__mio.policy.check\` and follow its suggestion when available.
53
+ - At the end of every task, before producing the final answer, call \`mcp__mio-intelligence__mio.observer.ingest\` with \`event_type\` = \`task_outcome\`, a stable \`trace_id\`, \`outcome\` = \`success\` / \`failure\` / \`aborted\`, and a brief non-sensitive \`payload\`. If Mio is unavailable, do not block the final answer.
54
+ - When you reuse a prior decision or experience returned by \`mcp__mio-intelligence__mio.memory.query\` and it changes your approach or outcome, call \`mcp__mio-intelligence__mio.experience.reuse\` with \`sourceAgent\`, \`targetAgent\`, \`experienceId\`, \`reuse\`, \`behaviorChanged\`, and \`outcomeImproved\`.
55
55
  - If Mio MCP is unavailable, do not block the task. Continue normally and note the missed memory or observation.
56
56
  - Do not store secrets, credentials, or raw sensitive content in Mio memory.
57
57
  ${MEMORY_END}`
@@ -86,6 +86,25 @@ function configNeedsRepair(serverScript) {
86
86
  return configured !== needle
87
87
  }
88
88
 
89
+ // Installed configs written before 0.13.4 baked an install-time project into
90
+ // MIO_CONTEXT, which overrides the server's git-based project derivation and
91
+ // mis-attributes memory writes. Rewriting the entry drops it; `cwd` pins the
92
+ // server process to the workspace so derivation has a stable starting point.
93
+ function contextNeedsRepair() {
94
+ const config = readConfig()
95
+ const mcp = config && config.mcp && config.mcp['mio-intelligence']
96
+ if (!mcp) return false
97
+ const raw = mcp.environment && mcp.environment.MIO_CONTEXT
98
+ if (typeof raw !== 'string') return true
99
+ let context
100
+ try {
101
+ context = JSON.parse(raw)
102
+ } catch (_) {
103
+ return true
104
+ }
105
+ return Boolean(context && typeof context === 'object' && ('project' in context || 'workspace' in context))
106
+ }
107
+
89
108
  function restartWarningMessage() {
90
109
  return restartWarning({
91
110
  hostLabel: 'OpenCode Desktop',
@@ -102,11 +121,11 @@ function isInstalled() {
102
121
  return instructions.includes(INSTRUCTIONS_BEGIN) && instructions.includes(INSTRUCTIONS_END)
103
122
  }
104
123
 
105
- function install({ node, serverScript, home, workspace, project }) {
124
+ function install({ node, serverScript, home }) {
106
125
  const target = configPath()
107
126
  const config = readConfig() || {}
108
127
  const configInstalled = Boolean(config.mcp && config.mcp['mio-intelligence'])
109
- const needsRepair = configInstalled && configNeedsRepair(serverScript)
128
+ const needsRepair = configInstalled && (configNeedsRepair(serverScript) || contextNeedsRepair())
110
129
 
111
130
  let configChanged = false
112
131
  let backup = null
@@ -120,8 +139,6 @@ function install({ node, serverScript, home, workspace, project }) {
120
139
  const existingMcp = config.mcp && typeof config.mcp === 'object' ? config.mcp : {}
121
140
  const context = JSON.stringify({
122
141
  agentId: 'opencode',
123
- project: project || path.basename(workspace || process.cwd()),
124
- workspace: workspace || process.cwd(),
125
142
  sessionId: 'opencode-session',
126
143
  })
127
144
  const next = {
@@ -132,6 +149,7 @@ function install({ node, serverScript, home, workspace, project }) {
132
149
  enabled: true,
133
150
  type: 'local',
134
151
  command: [node, serverScript],
152
+ cwd: '.',
135
153
  environment: {
136
154
  MIO_DATA_DIR: home,
137
155
  MIO_CONTEXT: context,
@@ -152,7 +170,7 @@ function install({ node, serverScript, home, workspace, project }) {
152
170
  if (configChanged && instructionsResult.changed) {
153
171
  message = 'OpenCode Mio MCP installed and global Mio rules injected into AGENTS.md. Restart OpenCode to load them.'
154
172
  } else if (needsRepair && !instructionsResult.changed) {
155
- message = 'OpenCode Mio MCP repaired (server path updated). Restart OpenCode to load it.'
173
+ message = 'OpenCode Mio MCP repaired (server path or context updated). Restart OpenCode to load it.'
156
174
  } else if (instructionsResult.changed) {
157
175
  message = 'OpenCode Mio MCP already installed. Injected global Mio rules into AGENTS.md. Restart OpenCode to load them.'
158
176
  } else {
@@ -173,4 +191,12 @@ function install({ node, serverScript, home, workspace, project }) {
173
191
  }
174
192
  }
175
193
 
176
- module.exports = { configPath, globalInstructionsPath, isInstalled, install, configNeedsRepair, restartWarningMessage }
194
+ module.exports = {
195
+ configPath,
196
+ globalInstructionsPath,
197
+ isInstalled,
198
+ install,
199
+ configNeedsRepair,
200
+ contextNeedsRepair,
201
+ restartWarningMessage,
202
+ }
@@ -55,12 +55,12 @@ const MEMORY_BLOCK = `${MEMORY_BEGIN}
55
55
 
56
56
  Mio MCP tools are available. Treat these as mandatory runtime rules for every workspace.
57
57
 
58
- - Before designing or implementing a solution, or when unsure about prior decisions, call \`mio-intelligence_mio.memory.query\` first. Omit \`project\` unless a specific project is required; Mio derives it from the current Git repository.
59
- - After a decision affecting architecture, deployment, dependencies, or long-term behavior, call \`mio-intelligence_mio.memory.record\` with \`kind\`: \`decision\`, \`context\`, \`problem\`, or \`note\`.
60
- - When a tool call fails, retries, or returns an error, call \`mio-intelligence_mio.observer.ingest\` with a stable \`trace_id\`, relevant \`event_type\`, \`payload\`, and \`outcome\`.
61
- - Before a high-risk operation such as deletion, migration, credential change, or production change, call \`mio-intelligence_mio.policy.check\` and follow its suggestion when available.
62
- - At the end of every task, before producing the final answer, call \`mio-intelligence_mio.observer.ingest\` with \`event_type\` = \`task_outcome\`, a stable \`trace_id\`, \`outcome\` = \`success\` / \`failure\` / \`aborted\`, and a brief non-sensitive \`payload\`. If Mio is unavailable, do not block the final answer.
63
- - When you reuse a prior decision or experience returned by \`mio-intelligence_mio.memory.query\` and it changes your approach or outcome, call \`mio-intelligence_mio.experience.reuse\` with \`sourceAgent\`, \`targetAgent\`, \`experienceId\`, \`reuse\`, \`behaviorChanged\`, and \`outcomeImproved\`.
58
+ - Before designing or implementing a solution, or when unsure about prior decisions, call \`mcp__mio-intelligence__mio.memory.query\` first. Omit \`project\` unless a specific project is required; Mio derives it from the current Git repository.
59
+ - After a decision affecting architecture, deployment, dependencies, or long-term behavior, call \`mcp__mio-intelligence__mio.memory.record\` with \`kind\`: \`decision\`, \`context\`, \`problem\`, or \`note\`.
60
+ - When a tool call fails, retries, or returns an error, call \`mcp__mio-intelligence__mio.observer.ingest\` with a stable \`trace_id\`, relevant \`event_type\`, \`payload\`, and \`outcome\`.
61
+ - Before a high-risk operation such as deletion, migration, credential change, or production change, call \`mcp__mio-intelligence__mio.policy.check\` and follow its suggestion when available.
62
+ - At the end of every task, before producing the final answer, call \`mcp__mio-intelligence__mio.observer.ingest\` with \`event_type\` = \`task_outcome\`, a stable \`trace_id\`, \`outcome\` = \`success\` / \`failure\` / \`aborted\`, and a brief non-sensitive \`payload\`. If Mio is unavailable, do not block the final answer.
63
+ - When you reuse a prior decision or experience returned by \`mcp__mio-intelligence__mio.memory.query\` and it changes your approach or outcome, call \`mcp__mio-intelligence__mio.experience.reuse\` with \`sourceAgent\`, \`targetAgent\`, \`experienceId\`, \`reuse\`, \`behaviorChanged\`, and \`outcomeImproved\`.
64
64
  - If Mio MCP is unavailable, do not block the task. Continue normally and note the missed memory or observation.
65
65
  - Do not store secrets, credentials, or raw sensitive content in Mio memory.
66
66
  ${MEMORY_END}`
@@ -124,7 +124,7 @@ function writeApproval(name, entry) {
124
124
  fs.writeFileSync(target, `${JSON.stringify(approvals, null, 2)}\n`, 'utf8')
125
125
  }
126
126
 
127
- function install({ node, serverScript, home, workspace, project }) {
127
+ function install({ node, serverScript, home, workspace }) {
128
128
  const target = configPath()
129
129
  const canonical = readJson(target) || {}
130
130
  const legacy = readJson(legacyConfigPath()) || {}
@@ -142,10 +142,11 @@ function install({ node, serverScript, home, workspace, project }) {
142
142
  },
143
143
  }
144
144
 
145
+ // No project/workspace here: the MCP server derives the project from its own
146
+ // git cwd. A baked value from install-time cwd overrides that derivation and
147
+ // mis-attributes every memory write for the host's lifetime.
145
148
  const context = JSON.stringify({
146
149
  agentId: 'workbuddy',
147
- project: project || path.basename(workspace || process.cwd()),
148
- workspace: workspace || process.cwd(),
149
150
  sessionId: 'workbuddy-session',
150
151
  })
151
152
 
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)
@@ -1918,6 +1922,66 @@ async function observerPipelineCommand(args, useJson) {
1918
1922
  printObserverPipeline(result)
1919
1923
  }
1920
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
+
1921
1985
  function observerCommand(args, useJson) {
1922
1986
  const sub = args[1]
1923
1987
  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
@@ -1933,6 +1997,7 @@ function observerCommand(args, useJson) {
1933
1997
  if (sub === 'collect') return observerCollectCommand(args, useJson)
1934
1998
  if (sub === 'ferment') return observerFermentCommand(args, useJson)
1935
1999
  if (sub === 'pipeline') return observerPipelineCommand(args, useJson)
2000
+ if (sub === 'serve') return observerServeCommand(args, useJson)
1936
2001
 
1937
2002
  if (!['status', 'world-model', 'trends', 'research', 'insights', 'essays', 'dag'].includes(sub)) {
1938
2003
  console.error(`Unknown observer subcommand: ${sub}`)
@@ -3098,26 +3163,67 @@ function mcpCommand() {
3098
3163
  require(SERVER_SCRIPT)
3099
3164
  }
3100
3165
 
3101
- 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) {
3102
3178
  const flags = args.slice(1)
3103
- 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')
3104
3184
  if (flags.includes('--start')) {
3105
3185
  const result = observer.startBackground(MIO_HOME)
3106
- 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
+ }
3107
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
+ }
3108
3196
  return
3109
3197
  }
3110
3198
  if (flags.includes('--stop')) {
3111
3199
  const result = observer.stopBackground(MIO_HOME)
3112
- 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
+ }
3113
3207
  console.log(result.stopped ? 'Observer stopped.' : 'Observer was not running.')
3208
+ console.log(researchResult.stopped ? 'Research scheduler stopped.' : 'Research scheduler was not running.')
3114
3209
  return
3115
3210
  }
3116
3211
  if (flags.includes('--status')) {
3117
3212
  const running = observer.isRunning(MIO_HOME)
3118
3213
  const pid = observer.readPid(MIO_HOME)
3119
- 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)
3120
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
3121
3227
  return
3122
3228
  }
3123
3229
  if (flags.includes('--once')) {
@@ -3168,7 +3274,7 @@ Usage:
3168
3274
  mio evolution migration plan Preview state migration diffs (--legacy-records/--modular-records JSON; required)
3169
3275
  mio evolution cutover apply --dry-run Dry-run a cutover plan (--plan JSON and --dry-run are both required)
3170
3276
  mio observe Watch WorkBuddy transcripts and auto-ingest task outcomes
3171
- mio observe --start|--stop|--status|--once Manage the background observer daemon
3277
+ mio observe --start [--research]|--stop|--status|--once Manage the background observer daemon
3172
3278
  mio recall "<query>" Search Mio memory from the terminal (same ranking as mio.memory.query)
3173
3279
  mio traces Show recent observer traces (--type/--outcome/--agent/--since/--limit/--compact)
3174
3280
  mio remember "<content>" Write a memory record from the terminal (same schema as mio.memory.record)
@@ -3197,6 +3303,7 @@ Usage:
3197
3303
  mio observer collect Fetch from the configured sources (--sources/--keywords/--limit)
3198
3304
  mio observer ferment Run the fermentation engine (--session morning|afternoon|night)
3199
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)
3200
3307
  mio phase0 report Show the Phase 0 validation report (--project X, --format markdown)
3201
3308
  mio host capabilities Show what each host supports and whether it is installed
3202
3309
  mio task route "<task>" Which verified experiences apply to this task (--project/--scope/--limit)
@@ -3232,7 +3339,7 @@ async function main() {
3232
3339
  case 'agents':
3233
3340
  return agentsCommand(args, useJson)
3234
3341
  case 'observe':
3235
- return observeCommand(args)
3342
+ return observeCommand(args, useJson)
3236
3343
  case 'recall':
3237
3344
  return recallCommand(args, useJson)
3238
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.2",
3
+ "version": "0.13.4",
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.2",
36
+ "@akemi-mio/observer": "^0.1.3",
37
37
  "@akemi-mio/insight": "^0.1.0"
38
38
  },
39
39
  "main": "index.js",
@@ -332,6 +332,64 @@ function createObserverStore(options = {}) {
332
332
  }
333
333
  }
334
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
+
335
393
  return {
336
394
  baseDir: defaultBaseDir,
337
395
  status,
@@ -344,6 +402,7 @@ function createObserverStore(options = {}) {
344
402
  collect,
345
403
  ferment,
346
404
  pipeline,
405
+ serve,
347
406
  }
348
407
  }
349
408