mio-agent-runtime 0.2.7 → 0.3.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/README.md CHANGED
@@ -18,25 +18,46 @@ mio mcp Start Mio MCP server (stdio)
18
18
  mio install <host> Install Mio into a host (codex|opencode|workbuddy)
19
19
  mio status Show runtime and adapter status
20
20
  mio agents List installed agents
21
+ mio observe Watch WorkBuddy transcripts and auto-ingest task outcomes (foreground)
22
+ mio observe --start|--stop|--status|--once Manage the background observer daemon
21
23
  mio --json status Machine-readable status
22
24
  mio --json agents Machine-readable agents
23
25
  ```
24
26
 
25
- ## Host notes
26
-
27
- - OpenCode uses `~/.config/opencode/opencode.json` for MCP registration and
28
- `~/.config/opencode/AGENTS.md` for global instructions. `mio install opencode`
29
- writes both files and injects Mio usage rules into `AGENTS.md` so OpenCode
30
- calls Mio memory/observer/policy automatically. If OpenCode is already
31
- running, restart it once so it reloads both files.
32
- - WorkBuddy uses `~/.workbuddy/mcp.json` and requires each custom MCP server to
33
- be approved in `~/.workbuddy/mcp-approvals.json`. `mio install workbuddy`
34
- writes both files and injects Mio usage rules into
35
- `~/.workbuddy/CODEBUDDY.md` (user-level memory rules, auto-loaded by
36
- WorkBuddy into every session; the CLI hardcodes the CodeBuddy product name, so the file is CODEBUDDY.md not WORKBUDDY.md) plus `<workspace>/AGENTS.md` (project-level).
37
- If WorkBuddy is already running, restart it once so it reloads the approval
27
+ ## Host notes
28
+
29
+ - OpenCode uses `~/.config/opencode/opencode.json` for MCP registration and
30
+ `~/.config/opencode/AGENTS.md` for global instructions. `mio install opencode`
31
+ writes both files and injects Mio usage rules into `AGENTS.md` so OpenCode
32
+ calls Mio memory/observer/policy automatically. If OpenCode is already
33
+ running, restart it once so it reloads both files.
34
+ - WorkBuddy uses `~/.workbuddy/mcp.json` and requires each custom MCP server to
35
+ be approved in `~/.workbuddy/mcp-approvals.json`. `mio install workbuddy`
36
+ writes both files and injects Mio usage rules into
37
+ `~/.workbuddy/CODEBUDDY.md` (user-level memory rules, auto-loaded by
38
+ WorkBuddy into every session; the CLI hardcodes the CodeBuddy product name, so the file is CODEBUDDY.md not WORKBUDDY.md) plus `<workspace>/AGENTS.md` (project-level).
39
+ If WorkBuddy is already running, restart it once so it reloads the approval
38
40
  and memory rule files.
39
41
 
42
+ ## Passive observer (`mio observe`)
43
+
44
+ WorkBuddy 的默认模型不一定会主动调用 Mio MCP 工具,即使规则已注入
45
+ (`CODEBUDDY.md` / `AGENTS.md`)。`mio observe` 是一个不依赖模型自觉的
46
+ 兜底:它持续监听 `~/.workbuddy/projects/**/*.jsonl` 会话记录,把每个任务的
47
+ 结果(`task_outcome`)、工具调用错误(`error`)以及完成任务的摘要自动写入
48
+ `MIO_HOME/traces.jsonl` 和 `MIO_HOME/memory.jsonl`(与 MCP 服务同一套
49
+ schema,`source=workbuddy-observer`)。
50
+
51
+ - `mio install <host>` 会自动执行一次回填并启动后台观察器。
52
+
53
+ - 除了写入 `MIO_HOME`,观察器还会把任务摘要**写回 WorkBuddy 自己的记忆文件**
54
+ (`<workspace>/.workbuddy/memory/YYYY-MM-DD.md`,标为 `## Mio 自动摘要`)。
55
+ WorkBuddy 的系统提示要求模型每次会话维护并阅读这些文件,所以下一次会话
56
+ WorkBuddy 会**被动取到** Mio 记录的内容——单 Agent 的「存 + 取」闭环不依赖
57
+ 模型调用 Mio 工具。
58
+ - 已处理的文件偏移记录在 `MIO_HOME/observe-state.json`,不会重复写入。
59
+ - 只回填最近 24h 内更新的会话,避免把历史记录灌入记忆库。
60
+
40
61
  ## MIO_HOME
41
62
 
42
63
  默认 `~/.mio-intelligence`,可通过环境变量 `MIO_HOME` 覆盖。
@@ -50,6 +71,8 @@ packages/mio-cli
50
71
  │ ├── codex.js
51
72
  │ ├── opencode.js
52
73
  │ └── workbuddy.js
74
+ ├── observe/
75
+ │ └── observer.js
53
76
  ├── server/
54
77
  │ └── mio-intelligence-mcp/index.js
55
78
  └── package.json
package/bin/mio.js CHANGED
@@ -14,6 +14,7 @@ const adapters = {
14
14
  opencode: require('../adapters/opencode.js'),
15
15
  workbuddy: require('../adapters/workbuddy.js'),
16
16
  }
17
+ const observer = require('../observe/observer.js')
17
18
 
18
19
  function ensureHome() {
19
20
  fs.mkdirSync(MIO_HOME, { recursive: true })
@@ -51,33 +52,36 @@ function initCommand() {
51
52
  console.log(`MIO_HOME ready: ${MIO_HOME}`)
52
53
  }
53
54
 
54
- function statusCommand(useJson) {
55
- const config = readConfig()
56
- const installed = {
57
- codex: adapters.codex.isInstalled(),
58
- opencode: adapters.opencode.isInstalled(),
59
- workbuddy: adapters.workbuddy.isInstalled(),
60
- }
61
- const payload = {
62
- version: config.version,
63
- home: MIO_HOME,
64
- serverScript: SERVER_SCRIPT,
65
- serverScriptExists: fs.existsSync(SERVER_SCRIPT),
66
- agents: config.agents || {},
67
- codexInstalled: installed.codex,
68
- opencodeInstalled: installed.opencode,
69
- workbuddyInstalled: installed.workbuddy,
70
- }
55
+ function statusCommand(useJson) {
56
+ const config = readConfig()
57
+ const installed = {
58
+ codex: adapters.codex.isInstalled(),
59
+ opencode: adapters.opencode.isInstalled(),
60
+ workbuddy: adapters.workbuddy.isInstalled(),
61
+ }
62
+ const payload = {
63
+ version: config.version,
64
+ home: MIO_HOME,
65
+ serverScript: SERVER_SCRIPT,
66
+ serverScriptExists: fs.existsSync(SERVER_SCRIPT),
67
+ agents: config.agents || {},
68
+ codexInstalled: installed.codex,
69
+ opencodeInstalled: installed.opencode,
70
+ workbuddyInstalled: installed.workbuddy,
71
+ observerRunning: observer.isRunning(MIO_HOME),
72
+ observerPid: observer.isRunning(MIO_HOME) ? observer.readPid(MIO_HOME) : null,
73
+ }
71
74
  if (useJson) {
72
75
  jsonOrText(payload, true)
73
76
  return
74
77
  }
75
- console.log(`MIO_HOME: ${MIO_HOME}`)
76
- console.log(`MCP Server: ${SERVER_SCRIPT}`)
77
- console.log(`Codex MCP: ${installed.codex ? 'installed' : 'not installed'}`)
78
- console.log(`OpenCode MCP: ${installed.opencode ? 'installed' : 'not installed'}`)
79
- console.log(`WorkBuddy MCP: ${installed.workbuddy ? 'installed' : 'not installed'}`)
80
- }
78
+ console.log(`MIO_HOME: ${MIO_HOME}`)
79
+ console.log(`MCP Server: ${SERVER_SCRIPT}`)
80
+ console.log(`Codex MCP: ${installed.codex ? 'installed' : 'not installed'}`)
81
+ console.log(`OpenCode MCP: ${installed.opencode ? 'installed' : 'not installed'}`)
82
+ console.log(`WorkBuddy MCP: ${installed.workbuddy ? 'installed' : 'not installed'}`)
83
+ console.log(`Observer: ${observer.isRunning(MIO_HOME) ? 'running (pid ' + observer.readPid(MIO_HOME) + ')' : 'not running'}`)
84
+ }
81
85
 
82
86
  function agentsCommand(useJson) {
83
87
  const config = readConfig()
@@ -131,17 +135,69 @@ function installCommand(host, useJson) {
131
135
  }
132
136
  writeConfig(config)
133
137
  }
138
+ try {
139
+ observer.runCycle(MIO_HOME, observer.loadState(MIO_HOME), observer.createSinks(MIO_HOME))
140
+ } catch (_) {}
141
+ let observerStarted = false
142
+ try {
143
+ observerStarted = observer.startBackground(MIO_HOME).started
144
+ } catch (_) {}
134
145
  if (useJson) {
146
+ result.observer = {
147
+ running: observer.isRunning(MIO_HOME),
148
+ started: observerStarted,
149
+ }
135
150
  jsonOrText(result, true)
136
151
  return
137
152
  }
138
153
  console.log(result.message || `${host} adapter completed.`)
154
+ console.log(
155
+ observer.isRunning(MIO_HOME)
156
+ ? 'Observer daemon running.'
157
+ : 'Observer daemon not running. Run `mio observe --start`.'
158
+ )
139
159
  }
140
160
 
141
161
  function mcpCommand() {
142
162
  require(SERVER_SCRIPT)
143
163
  }
144
164
 
165
+ function observeCommand(args) {
166
+ const flags = args.slice(1)
167
+ const useJson = flags.includes('--json')
168
+ if (flags.includes('--start')) {
169
+ const result = observer.startBackground(MIO_HOME)
170
+ if (useJson) return jsonOrText(result, true)
171
+ console.log(result.started ? 'Observer started.' : 'Observer already running.')
172
+ return
173
+ }
174
+ if (flags.includes('--stop')) {
175
+ const result = observer.stopBackground(MIO_HOME)
176
+ if (useJson) return jsonOrText(result, true)
177
+ console.log(result.stopped ? 'Observer stopped.' : 'Observer was not running.')
178
+ return
179
+ }
180
+ if (flags.includes('--status')) {
181
+ const running = observer.isRunning(MIO_HOME)
182
+ const pid = observer.readPid(MIO_HOME)
183
+ if (useJson) return jsonOrText({ running, pid }, true)
184
+ console.log(running ? 'Observer: running (pid ' + pid + ')' : 'Observer: not running')
185
+ return
186
+ }
187
+ if (flags.includes('--once')) {
188
+ const res = observer.runCycle(
189
+ MIO_HOME,
190
+ observer.loadState(MIO_HOME),
191
+ observer.createSinks(MIO_HOME)
192
+ )
193
+ if (useJson) return jsonOrText(res, true)
194
+ console.log('Observed files=' + res.files + ' lines=' + res.lines + ' outcomes=' + res.outcomes)
195
+ return
196
+ }
197
+ console.log('Observing WorkBuddy transcripts into ' + MIO_HOME + ' (Ctrl+C to stop)...')
198
+ observer.startForeground(MIO_HOME)
199
+ }
200
+
145
201
  function help() {
146
202
  console.log(`Mio Agent Runtime CLI
147
203
 
@@ -151,6 +207,8 @@ Usage:
151
207
  mio install <host> Install Mio into a host (codex|opencode|workbuddy)
152
208
  mio status Show runtime and adapter status
153
209
  mio agents List installed agents
210
+ mio observe Watch WorkBuddy transcripts and auto-ingest task outcomes
211
+ mio observe --start|--stop|--status|--once Manage the background observer daemon
154
212
  mio --json status Machine-readable status
155
213
  mio --json agents Machine-readable agents
156
214
  `)
@@ -174,6 +232,8 @@ function main() {
174
232
  return statusCommand(useJson)
175
233
  case 'agents':
176
234
  return agentsCommand(useJson)
235
+ case 'observe':
236
+ return observeCommand(args)
177
237
  case 'help':
178
238
  case '--help':
179
239
  return help()
@@ -184,4 +244,4 @@ function main() {
184
244
  }
185
245
  }
186
246
 
187
- main()
247
+ main()
@@ -0,0 +1,481 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const os = require('os')
7
+ const crypto = require('crypto')
8
+ const { spawn } = require('child_process')
9
+
10
+ const INTERVAL_MS = 3000
11
+ // A WorkBuddy turn is considered finished when its transcript file has not
12
+ // been appended for this long AND an assistant answer is present.
13
+ const IDLE_MS = 15000
14
+
15
+ function homeDir() {
16
+ return process.env.MIO_HOME || path.join(os.homedir(), '.mio-intelligence')
17
+ }
18
+
19
+ function workbuddyConfigDir() {
20
+ return (
21
+ process.env.WORKBUDDY_CONFIG_DIR ||
22
+ process.env.CODEBUDDY_CONFIG_DIR ||
23
+ path.join(os.homedir(), '.workbuddy')
24
+ )
25
+ }
26
+
27
+ function createId(prefix) {
28
+ return prefix + '_' + Date.now() + '_' + crypto.randomBytes(6).toString('hex')
29
+ }
30
+
31
+ function cleanText(text) {
32
+ return String(text || '')
33
+ .replace(/\r/g, '')
34
+ .replace(/#{2,}|\*{2,}|`{1,}|_{2,}|~{2,}|-{2,}/g, ' ')
35
+ .replace(/\s{2,}/g, ' ')
36
+ .trim()
37
+ }
38
+
39
+ function projectFromDirName(dirName) {
40
+ // WorkBuddy project dirs encode the workspace path with dashes, e.g.
41
+ // "d-work-code-douyin_store" -> "douyin_store"
42
+ const parts = String(dirName || '').split('-').filter(Boolean)
43
+ if (parts.length > 1 && /^\\d+$/.test(parts[parts.length - 1])) {
44
+ return dirName || 'unknown'
45
+ }
46
+ return parts.length > 1 ? parts[parts.length - 1] : dirName || 'unknown'
47
+ }
48
+
49
+ function log(home, message) {
50
+ const line = '[' + new Date().toISOString() + '] ' + message + '\n'
51
+ try {
52
+ fs.mkdirSync(path.join(home, 'logs'), { recursive: true })
53
+ fs.appendFileSync(path.join(home, 'logs', 'observe.log'), line, 'utf8')
54
+ } catch (_) {}
55
+ if (process.stdout.isTTY || process.env.MIO_OBSERVE_VERBOSE) {
56
+ process.stdout.write(line)
57
+ }
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // state
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function stateFile(home) {
65
+ return path.join(home, 'observe-state.json')
66
+ }
67
+
68
+ function loadState(home) {
69
+ try {
70
+ const parsed = JSON.parse(fs.readFileSync(stateFile(home), 'utf8'))
71
+ if (parsed && typeof parsed === 'object') {
72
+ return {
73
+ version: 1,
74
+ files: parsed.files || {},
75
+ buffers: parsed.buffers || {},
76
+ openTurns: parsed.openTurns || {},
77
+ }
78
+ }
79
+ } catch (_) {}
80
+ return { version: 1, files: {}, buffers: {}, openTurns: {} }
81
+ }
82
+
83
+ function saveState(home, state) {
84
+ try {
85
+ fs.mkdirSync(home, { recursive: true })
86
+ fs.writeFileSync(stateFile(home), JSON.stringify(state) + '\n', 'utf8')
87
+ } catch (_) {}
88
+ }
89
+
90
+ function listTranscripts() {
91
+ const root = path.join(workbuddyConfigDir(), 'projects')
92
+ const out = []
93
+ if (!fs.existsSync(root)) return out
94
+ const walk = (dir) => {
95
+ let entries
96
+ try {
97
+ entries = fs.readdirSync(dir, { withFileTypes: true })
98
+ } catch (_) {
99
+ return
100
+ }
101
+ for (const entry of entries) {
102
+ const full = path.join(dir, entry.name)
103
+ if (entry.isDirectory()) walk(full)
104
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full)
105
+ }
106
+ }
107
+ walk(root)
108
+ return out
109
+ }
110
+
111
+ function fileMeta(file) {
112
+ const dirName = path.basename(path.dirname(file))
113
+ return {
114
+ sessionId: path.basename(file, '.jsonl'),
115
+ project: projectFromDirName(dirName),
116
+ }
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // sinks (write into MIO_HOME with the same schema as the MCP server)
121
+ // ---------------------------------------------------------------------------
122
+
123
+ function appendJsonl(file, value) {
124
+ fs.mkdirSync(path.dirname(file), { recursive: true })
125
+ fs.appendFileSync(file, JSON.stringify(value) + '\n', 'utf8')
126
+ }
127
+
128
+ function createSinks(home) {
129
+ const tracePath = path.join(home, 'traces.jsonl')
130
+ const memoryPath = path.join(home, 'memory.jsonl')
131
+ const seenTraceIds = new Set()
132
+ const seenMemoryContents = new Set()
133
+
134
+ function trace(event) {
135
+ const record = {
136
+ id: createId('trace'),
137
+ timestamp: new Date().toISOString(),
138
+ trace_id: event.trace_id,
139
+ event_type: event.event_type,
140
+ outcome: event.outcome || null,
141
+ payload: event.payload || {},
142
+ agent: event.agent || 'workbuddy-observer',
143
+ host: event.host || 'workbuddy-observer',
144
+ project: event.project || 'unknown',
145
+ }
146
+ if (seenTraceIds.has(record.trace_id)) return
147
+ seenTraceIds.add(record.trace_id)
148
+ if (seenTraceIds.size > 20000) seenTraceIds.clear()
149
+ appendJsonl(tracePath, record)
150
+ }
151
+
152
+ function memory(entry) {
153
+ const key = entry.project + '|' + entry.content
154
+ if (seenMemoryContents.has(key)) return
155
+ seenMemoryContents.add(key)
156
+ const record = {
157
+ id: createId('mem'),
158
+ timestamp: new Date().toISOString(),
159
+ kind: entry.kind || 'note',
160
+ content: entry.content,
161
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
162
+ project: entry.project || 'unknown',
163
+ source: entry.source || 'workbuddy-observer',
164
+ }
165
+ appendJsonl(memoryPath, record)
166
+ }
167
+
168
+ return { trace, memory }
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // WorkBuddy transcript turn machine
173
+ // ---------------------------------------------------------------------------
174
+
175
+ function createTurn(meta) {
176
+ return {
177
+ id: null,
178
+ ts: null,
179
+ project: meta.project,
180
+ sessionId: meta.sessionId,
181
+ cwd: meta.cwd || null,
182
+ toolCount: 0,
183
+ errors: [],
184
+ lastAssistant: null,
185
+ }
186
+ }
187
+
188
+ function feedWorkbuddyLine(turn, obj) {
189
+ if (!obj || typeof obj !== 'object') return { finalized: null, next: turn }
190
+ let finalized = null
191
+ if (obj.cwd) turn.cwd = obj.cwd
192
+ if (obj.type === 'message') {
193
+ if (obj.role === 'user') {
194
+ finalized = turn
195
+ const next = createTurn(turn)
196
+ next.id = obj.id
197
+ next.ts = obj.timestamp
198
+ next.cwd = obj.cwd || turn.cwd
199
+ return { finalized, next: next }
200
+ }
201
+ if (obj.role === 'assistant') {
202
+ const text = (Array.isArray(obj.content) ? obj.content : [])
203
+ .map((c) => (c && typeof c.text === 'string' ? c.text : ''))
204
+ .filter(Boolean)
205
+ .join('\n')
206
+ turn.lastAssistant = {
207
+ status: obj.status,
208
+ text: text,
209
+ error: (obj.providerData && obj.providerData.error) || null,
210
+ }
211
+ }
212
+ } else if (obj.type === 'function_call') {
213
+ turn.toolCount += 1
214
+ } else if (obj.type === 'function_call_result') {
215
+ const status = String(obj.status || (obj.is_error ? 'error' : 'completed'))
216
+ if (status !== 'completed') {
217
+ turn.errors.push({ callId: obj.callId, name: obj.name, status: status })
218
+ if (turn.errors.length > 20) turn.errors.shift()
219
+ }
220
+ }
221
+ return { finalized: finalized, next: turn }
222
+ }
223
+
224
+ function localDateKey(ts) {
225
+ const d = new Date(Number(ts) || Date.now())
226
+ const y = d.getFullYear()
227
+ const m = String(d.getMonth() + 1).padStart(2, '0')
228
+ const day = String(d.getDate()).padStart(2, '0')
229
+ return y + '-' + m + '-' + day
230
+ }
231
+
232
+ // Write a compact Mio digest back into WorkBuddy's own memory file
233
+ // (<workspace>/.workbuddy/memory/YYYY-MM-DD.md). WorkBuddy instructs its model
234
+ // to maintain and read these files every session, so the digest is
235
+ // 'retrieved' next session without any Mio tool call by the model.
236
+ function writeWorkbuddyDigest(turn, outcome, summary) {
237
+ if (!turn.cwd || !summary) return
238
+ const dir = path.join(turn.cwd, '.workbuddy', 'memory')
239
+ const file = path.join(dir, localDateKey(turn.ts) + '.md')
240
+ try {
241
+ fs.mkdirSync(dir, { recursive: true })
242
+ const time = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
243
+ const entry =
244
+ '\n## Mio 自动摘要 (' + time + ')\n' +
245
+ '- outcome: ' + outcome + ' | tools: ' + turn.toolCount + ' | ' + summary.slice(0, 160) + '\n'
246
+ fs.appendFileSync(file, entry, 'utf8')
247
+ } catch (_) {}
248
+ }
249
+
250
+ function emitWorkbuddyOutcome(turn, sinks) {
251
+ const la = turn.lastAssistant
252
+ if (!la) return false
253
+ let outcome
254
+ if (la.status === 'completed') outcome = 'success'
255
+ else if (la.error) outcome = 'failure'
256
+ else outcome = 'aborted'
257
+
258
+ const summary = cleanText(la.text)
259
+ const turnToken = String(turn.id || 'unknown').slice(0, 8)
260
+ const tsToken = turn.ts ? Math.floor(Number(turn.ts) / 1000) : Math.floor(Date.now() / 1000)
261
+
262
+ sinks.trace({
263
+ trace_id: 'workbuddy:' + turn.sessionId + ':' + turnToken + ':' + tsToken,
264
+ event_type: 'task_outcome',
265
+ outcome: outcome,
266
+ payload: {
267
+ host: 'workbuddy',
268
+ project: turn.project,
269
+ sessionId: turn.sessionId,
270
+ turnId: turn.id,
271
+ toolCalls: turn.toolCount,
272
+ errors: turn.errors.length,
273
+ summary: summary.slice(0, 300),
274
+ },
275
+ agent: 'workbuddy',
276
+ host: 'workbuddy',
277
+ project: turn.project,
278
+ })
279
+
280
+ if (outcome === 'success' && summary.length >= 40) {
281
+ sinks.memory({
282
+ kind: 'note',
283
+ content: '[workbuddy] 任务完成: ' + summary.slice(0, 160),
284
+ tags: ['task-outcome', 'workbuddy', turn.project],
285
+ project: turn.project,
286
+ source: 'workbuddy-observer',
287
+ })
288
+ writeWorkbuddyDigest(turn, outcome, summary)
289
+ }
290
+
291
+ for (const err of turn.errors) {
292
+ sinks.trace({
293
+ trace_id: 'workbuddy:' + turn.sessionId + ':' + turnToken + ':' + String(err.callId || 'err').slice(0, 12),
294
+ event_type: 'error',
295
+ outcome: 'error',
296
+ payload: {
297
+ host: 'workbuddy',
298
+ project: turn.project,
299
+ sessionId: turn.sessionId,
300
+ turnId: turn.id,
301
+ tool: err.name,
302
+ status: err.status,
303
+ },
304
+ agent: 'workbuddy',
305
+ host: 'workbuddy',
306
+ project: turn.project,
307
+ })
308
+ }
309
+ return true
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // cycle
314
+ // ---------------------------------------------------------------------------
315
+
316
+ function runCycle(home, state, sinks, opts) {
317
+ const out = { files: 0, lines: 0, outcomes: 0 }
318
+ fs.mkdirSync(home, { recursive: true })
319
+ const files = listTranscripts()
320
+ const now = Date.now()
321
+ const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
322
+
323
+ for (const file of files) {
324
+ out.files += 1
325
+ let st
326
+ try {
327
+ st = fs.statSync(file)
328
+ } catch (_) {
329
+ continue
330
+ }
331
+ const info = state.files[file] || { size: 0 }
332
+ // Do not flood MIO_HOME with ancient history: only backfill files that are
333
+ // already tracked or that changed within the max age window.
334
+ if (!state.files[file] && now - st.mtimeMs > maxAgeMs) continue
335
+ const size = st.size
336
+ if (size < info.size) {
337
+ // rotated/truncated -> restart from the beginning
338
+ info.size = 0
339
+ state.buffers[file] = ''
340
+ }
341
+
342
+ const meta = fileMeta(file)
343
+ let turn = state.openTurns[file] || createTurn(meta)
344
+ turn.project = meta.project
345
+ turn.sessionId = meta.sessionId
346
+
347
+ if (size > info.size) {
348
+ let chunk = ''
349
+ try {
350
+ const fd = fs.openSync(file, 'r')
351
+ const buf = Buffer.alloc(size - info.size)
352
+ fs.readSync(fd, buf, 0, buf.length, info.size)
353
+ fs.closeSync(fd)
354
+ chunk = buf.toString('utf8')
355
+ } catch (_) {
356
+ continue
357
+ }
358
+ const pending = (state.buffers[file] || '') + chunk
359
+ const lines = pending.split(/\r?\n/)
360
+ state.buffers[file] = lines.pop()
361
+ for (const line of lines) {
362
+ if (!line.trim()) continue
363
+ out.lines += 1
364
+ let obj
365
+ try {
366
+ obj = JSON.parse(line)
367
+ } catch (_) {
368
+ continue
369
+ }
370
+ const result = feedWorkbuddyLine(turn, obj)
371
+ if (result.finalized) {
372
+ if (emitWorkbuddyOutcome(result.finalized, sinks)) out.outcomes += 1
373
+ }
374
+ turn = result.next
375
+ }
376
+ state.files[file] = { size: size, mtimeMs: st.mtimeMs }
377
+ }
378
+
379
+ if (turn.lastAssistant && now - st.mtimeMs > IDLE_MS) {
380
+ if (emitWorkbuddyOutcome(turn, sinks)) out.outcomes += 1
381
+ state.openTurns[file] = null
382
+ } else if (turn.id) {
383
+ state.openTurns[file] = turn
384
+ } else {
385
+ state.openTurns[file] = null
386
+ }
387
+ }
388
+
389
+ saveState(home, state)
390
+ return out
391
+ }
392
+
393
+ function startForeground(home) {
394
+ const state = loadState(home)
395
+ const sinks = createSinks(home)
396
+ log(home, 'observe started (home=' + home + ', interval=' + INTERVAL_MS + 'ms)')
397
+ const tick = () => {
398
+ try {
399
+ const res = runCycle(home, state, sinks)
400
+ if (res.lines > 0) {
401
+ log(home, 'cycle: files=' + res.files + ' lines=' + res.lines + ' outcomes=' + res.outcomes)
402
+ }
403
+ } catch (err) {
404
+ log(home, 'cycle error: ' + (err && err.stack ? err.stack : String(err)))
405
+ }
406
+ }
407
+ tick()
408
+ setInterval(tick, INTERVAL_MS)
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // background daemon control
413
+ // ---------------------------------------------------------------------------
414
+
415
+ function pidFile(home) {
416
+ return path.join(home, 'observe.pid')
417
+ }
418
+
419
+ function readPid(home) {
420
+ try {
421
+ const pid = Number(fs.readFileSync(pidFile(home), 'utf8').trim())
422
+ return Number.isInteger(pid) && pid > 0 ? pid : null
423
+ } catch (_) {
424
+ return null
425
+ }
426
+ }
427
+
428
+ function isRunning(home) {
429
+ const pid = readPid(home)
430
+ if (!pid) return false
431
+ try {
432
+ process.kill(pid, 0)
433
+ return true
434
+ } catch (_) {
435
+ return false
436
+ }
437
+ }
438
+
439
+ function startBackground(home) {
440
+ if (isRunning(home)) {
441
+ return { running: true, started: false, pid: readPid(home) }
442
+ }
443
+ const script = path.join(__dirname, '..', 'bin', 'mio.js')
444
+ const child = spawn(process.execPath, [script, 'observe'], {
445
+ detached: true,
446
+ stdio: 'ignore',
447
+ windowsHide: true,
448
+ })
449
+ child.unref()
450
+ fs.mkdirSync(home, { recursive: true })
451
+ fs.writeFileSync(pidFile(home), String(child.pid), 'utf8')
452
+ return { running: true, started: true, pid: child.pid }
453
+ }
454
+
455
+ function stopBackground(home) {
456
+ const pid = readPid(home)
457
+ if (pid) {
458
+ try {
459
+ process.kill(pid)
460
+ } catch (_) {}
461
+ try {
462
+ fs.unlinkSync(pidFile(home))
463
+ } catch (_) {}
464
+ }
465
+ return { running: false, stopped: Boolean(pid), pid: pid || null }
466
+ }
467
+
468
+ module.exports = {
469
+ INTERVAL_MS,
470
+ homeDir,
471
+ workbuddyConfigDir,
472
+ loadState,
473
+ saveState,
474
+ runCycle,
475
+ createSinks,
476
+ startForeground,
477
+ startBackground,
478
+ stopBackground,
479
+ isRunning,
480
+ readPid,
481
+ }
package/package.json CHANGED
@@ -1,23 +1,24 @@
1
- {
2
- "name": "mio-agent-runtime",
3
- "version": "0.2.7",
4
- "description": "Mio Agent Runtime installer and MCP intelligence layer",
5
- "license": "MIT",
6
- "type": "commonjs",
7
- "bin": {
8
- "mio": "bin/mio.js"
9
- },
10
- "files": [
11
- "bin/",
12
- "server/",
13
- "adapters/",
14
- "README.md"
15
- ],
16
- "scripts": {
17
- "check": "node --check bin/mio.js && node --check adapters/codex.js && node --check adapters/opencode.js && node --check adapters/workbuddy.js && node --check server/mio-intelligence-mcp/index.js",
18
- "prepack": "npm run check"
19
- },
20
- "engines": {
21
- "node": ">=18"
22
- }
23
- }
1
+ {
2
+ "name": "mio-agent-runtime",
3
+ "version": "0.3.1",
4
+ "description": "Mio Agent Runtime installer and MCP intelligence layer",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "mio": "bin/mio.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "server/",
13
+ "adapters/",
14
+ "observe/",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "check": "node --check bin/mio.js && node --check adapters/codex.js && node --check adapters/opencode.js && node --check adapters/workbuddy.js && node --check server/mio-intelligence-mcp/index.js && node --check observe/observer.js",
19
+ "prepack": "npm run check"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ }
24
+ }