mio-agent-runtime 0.2.6 → 0.3.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/README.md CHANGED
@@ -1,27 +1,29 @@
1
- # mio-agent-runtime
2
-
3
- Mio Agent Runtime 的可发布 CLI 包。
4
-
5
- 安装一次,所有 Agent 自动接入 Mio。
6
-
7
- ## Install
8
-
9
- ```bash
10
- npm install -g mio-agent-runtime
11
- ```
12
-
13
- ## Commands
14
-
15
- ```text
16
- mio init Initialize MIO_HOME
17
- mio mcp Start Mio MCP server (stdio)
18
- mio install <host> Install Mio into a host (codex|opencode|workbuddy)
19
- mio status Show runtime and adapter status
20
- mio agents List installed agents
21
- mio --json status Machine-readable status
22
- mio --json agents Machine-readable agents
23
- ```
24
-
1
+ # mio-agent-runtime
2
+
3
+ Mio Agent Runtime 的可发布 CLI 包。
4
+
5
+ 安装一次,所有 Agent 自动接入 Mio。
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g mio-agent-runtime
11
+ ```
12
+
13
+ ## Commands
14
+
15
+ ```text
16
+ mio init Initialize MIO_HOME
17
+ mio mcp Start Mio MCP server (stdio)
18
+ mio install <host> Install Mio into a host (codex|opencode|workbuddy)
19
+ mio status Show runtime and adapter status
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
23
+ mio --json status Machine-readable status
24
+ mio --json agents Machine-readable agents
25
+ ```
26
+
25
27
  ## Host notes
26
28
 
27
29
  - OpenCode uses `~/.config/opencode/opencode.json` for MCP registration and
@@ -32,25 +34,40 @@ mio --json agents Machine-readable agents
32
34
  - WorkBuddy uses `~/.workbuddy/mcp.json` and requires each custom MCP server to
33
35
  be approved in `~/.workbuddy/mcp-approvals.json`. `mio install workbuddy`
34
36
  writes both files and injects Mio usage rules into
35
- `~/.workbuddy/WORKBUDDY.md` (user-level memory rules, auto-loaded by
36
- WorkBuddy into every session) plus `<workspace>/AGENTS.md` (project-level).
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).
37
39
  If WorkBuddy is already running, restart it once so it reloads the approval
38
- and memory rule files.
39
-
40
- ## MIO_HOME
41
-
42
- 默认 `~/.mio-intelligence`,可通过环境变量 `MIO_HOME` 覆盖。
43
-
44
- ## Package layout
45
-
46
- ```text
47
- packages/mio-cli
48
- ├── bin/mio.js
49
- ├── adapters/
50
- │ ├── codex.js
51
- │ ├── opencode.js
52
- │ └── workbuddy.js
53
- ├── server/
54
- │ └── mio-intelligence-mcp/index.js
55
- └── package.json
40
+ and memory rule files.
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
+ - 已处理的文件偏移记录在 `MIO_HOME/observe-state.json`,不会重复写入。
53
+ - 只回填最近 24h 内更新的会话,避免把历史记录灌入记忆库。
54
+
55
+ ## MIO_HOME
56
+
57
+ 默认 `~/.mio-intelligence`,可通过环境变量 `MIO_HOME` 覆盖。
58
+
59
+ ## Package layout
60
+
61
+ ```text
62
+ packages/mio-cli
63
+ ├── bin/mio.js
64
+ ├── adapters/
65
+ │ ├── codex.js
66
+ │ ├── opencode.js
67
+ │ └── workbuddy.js
68
+ ├── observe/
69
+ │ └── observer.js
70
+ ├── server/
71
+ │ └── mio-intelligence-mcp/index.js
72
+ └── package.json
56
73
  ```
@@ -25,15 +25,16 @@ function approvalsPath() {
25
25
  return path.join(configDir(), 'mcp-approvals.json')
26
26
  }
27
27
 
28
- // User-level memory rules file. WorkBuddy's codebuddy CLI auto-loads
29
- // WORKBUDDY.md (or WORKBUDDY.mdc) from the config dir into every session
30
- // (MemoryLoader user scope -> system-reminder-md / prompt context).
28
+ // User-level memory rules file. WorkBuddy's codebuddy CLI (a CodeBuddy fork)
29
+ // auto-loads CODEBUDDY.md (or CODEBUDDY.mdc) from the config dir into every
30
+ // session (MemoryLoader user scope -> system-reminder-md / prompt context).
31
+ // Note: PRODUCT_UPPERCASE_NAME in the CLI bundle is "CODEBUDDY", not "WORKBUDDY".
31
32
  function memoryRulesPath() {
32
- return path.join(configDir(), 'WORKBUDDY.md')
33
+ return path.join(configDir(), 'CODEBUDDY.md')
33
34
  }
34
35
 
35
36
  // Project-level memory rules. WorkBuddy walks up from the workspace dir and
36
- // auto-loads WORKBUDDY.md / AGENTS.md (project scope) into every session.
37
+ // auto-loads CODEBUDDY.md / AGENTS.md (project scope) into every session.
37
38
  function projectInstructionsPath(workspace) {
38
39
  return path.join(workspace || process.cwd(), 'AGENTS.md')
39
40
  }
@@ -188,7 +189,7 @@ function install({ node, serverScript, home, workspace, project }) {
188
189
  userRulesChanged: userRules.changed,
189
190
  projectRulesChanged: projectRules ? projectRules.changed : false,
190
191
  backup,
191
- message: 'WorkBuddy Mio MCP installed, approved, and Mio rules injected into WORKBUDDY.md (auto-loaded user memory) and the workspace AGENTS.md. Restart WorkBuddy to load them.',
192
+ message: 'WorkBuddy Mio MCP installed, approved, and Mio rules injected into CODEBUDDY.md (auto-loaded user memory) and the workspace AGENTS.md. Restart WorkBuddy to load them.',
192
193
  }
193
194
  }
194
195
 
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,452 @@
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
+ toolCount: 0,
182
+ errors: [],
183
+ lastAssistant: null,
184
+ }
185
+ }
186
+
187
+ function feedWorkbuddyLine(turn, obj) {
188
+ if (!obj || typeof obj !== 'object') return { finalized: null, next: turn }
189
+ let finalized = null
190
+ if (obj.type === 'message') {
191
+ if (obj.role === 'user') {
192
+ finalized = turn
193
+ const next = createTurn(turn)
194
+ next.id = obj.id
195
+ next.ts = obj.timestamp
196
+ return { finalized, next: next }
197
+ }
198
+ if (obj.role === 'assistant') {
199
+ const text = (Array.isArray(obj.content) ? obj.content : [])
200
+ .map((c) => (c && typeof c.text === 'string' ? c.text : ''))
201
+ .filter(Boolean)
202
+ .join('\n')
203
+ turn.lastAssistant = {
204
+ status: obj.status,
205
+ text: text,
206
+ error: (obj.providerData && obj.providerData.error) || null,
207
+ }
208
+ }
209
+ } else if (obj.type === 'function_call') {
210
+ turn.toolCount += 1
211
+ } else if (obj.type === 'function_call_result') {
212
+ const status = String(obj.status || (obj.is_error ? 'error' : 'completed'))
213
+ if (status !== 'completed') {
214
+ turn.errors.push({ callId: obj.callId, name: obj.name, status: status })
215
+ if (turn.errors.length > 20) turn.errors.shift()
216
+ }
217
+ }
218
+ return { finalized: finalized, next: turn }
219
+ }
220
+
221
+ function emitWorkbuddyOutcome(turn, sinks) {
222
+ const la = turn.lastAssistant
223
+ if (!la) return
224
+ let outcome
225
+ if (la.status === 'completed') outcome = 'success'
226
+ else if (la.error) outcome = 'failure'
227
+ else outcome = 'aborted'
228
+
229
+ const summary = cleanText(la.text)
230
+ const turnToken = String(turn.id || 'unknown').slice(0, 8)
231
+ const tsToken = turn.ts ? Math.floor(Number(turn.ts) / 1000) : Math.floor(Date.now() / 1000)
232
+
233
+ sinks.trace({
234
+ trace_id: 'workbuddy:' + turn.sessionId + ':' + turnToken + ':' + tsToken,
235
+ event_type: 'task_outcome',
236
+ outcome: outcome,
237
+ payload: {
238
+ host: 'workbuddy',
239
+ project: turn.project,
240
+ sessionId: turn.sessionId,
241
+ turnId: turn.id,
242
+ toolCalls: turn.toolCount,
243
+ errors: turn.errors.length,
244
+ summary: summary.slice(0, 300),
245
+ },
246
+ agent: 'workbuddy',
247
+ host: 'workbuddy',
248
+ project: turn.project,
249
+ })
250
+
251
+ if (outcome === 'success' && summary.length >= 40) {
252
+ sinks.memory({
253
+ kind: 'note',
254
+ content: '[workbuddy] 任务完成: ' + summary.slice(0, 160),
255
+ tags: ['task-outcome', 'workbuddy', turn.project],
256
+ project: turn.project,
257
+ source: 'workbuddy-observer',
258
+ })
259
+ }
260
+
261
+ for (const err of turn.errors) {
262
+ sinks.trace({
263
+ trace_id: 'workbuddy:' + turn.sessionId + ':' + turnToken + ':' + String(err.callId || 'err').slice(0, 12),
264
+ event_type: 'error',
265
+ outcome: 'error',
266
+ payload: {
267
+ host: 'workbuddy',
268
+ project: turn.project,
269
+ sessionId: turn.sessionId,
270
+ turnId: turn.id,
271
+ tool: err.name,
272
+ status: err.status,
273
+ },
274
+ agent: 'workbuddy',
275
+ host: 'workbuddy',
276
+ project: turn.project,
277
+ })
278
+ }
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // cycle
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function runCycle(home, state, sinks, opts) {
286
+ const out = { files: 0, lines: 0, outcomes: 0 }
287
+ fs.mkdirSync(home, { recursive: true })
288
+ const files = listTranscripts()
289
+ const now = Date.now()
290
+ const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
291
+
292
+ for (const file of files) {
293
+ out.files += 1
294
+ let st
295
+ try {
296
+ st = fs.statSync(file)
297
+ } catch (_) {
298
+ continue
299
+ }
300
+ const info = state.files[file] || { size: 0 }
301
+ // Do not flood MIO_HOME with ancient history: only backfill files that are
302
+ // already tracked or that changed within the max age window.
303
+ if (!state.files[file] && now - st.mtimeMs > maxAgeMs) continue
304
+ const size = st.size
305
+ if (size < info.size) {
306
+ // rotated/truncated -> restart from the beginning
307
+ info.size = 0
308
+ state.buffers[file] = ''
309
+ }
310
+
311
+ const meta = fileMeta(file)
312
+ let turn = state.openTurns[file] || createTurn(meta)
313
+ turn.project = meta.project
314
+ turn.sessionId = meta.sessionId
315
+
316
+ if (size > info.size) {
317
+ let chunk = ''
318
+ try {
319
+ const fd = fs.openSync(file, 'r')
320
+ const buf = Buffer.alloc(size - info.size)
321
+ fs.readSync(fd, buf, 0, buf.length, info.size)
322
+ fs.closeSync(fd)
323
+ chunk = buf.toString('utf8')
324
+ } catch (_) {
325
+ continue
326
+ }
327
+ const pending = (state.buffers[file] || '') + chunk
328
+ const lines = pending.split(/\r?\n/)
329
+ state.buffers[file] = lines.pop()
330
+ for (const line of lines) {
331
+ if (!line.trim()) continue
332
+ out.lines += 1
333
+ let obj
334
+ try {
335
+ obj = JSON.parse(line)
336
+ } catch (_) {
337
+ continue
338
+ }
339
+ const result = feedWorkbuddyLine(turn, obj)
340
+ if (result.finalized) {
341
+ emitWorkbuddyOutcome(result.finalized, sinks)
342
+ out.outcomes += 1
343
+ }
344
+ turn = result.next
345
+ }
346
+ state.files[file] = { size: size, mtimeMs: st.mtimeMs }
347
+ }
348
+
349
+ if (turn.lastAssistant && now - st.mtimeMs > IDLE_MS) {
350
+ emitWorkbuddyOutcome(turn, sinks)
351
+ out.outcomes += 1
352
+ state.openTurns[file] = null
353
+ } else if (turn.id) {
354
+ state.openTurns[file] = turn
355
+ } else {
356
+ state.openTurns[file] = null
357
+ }
358
+ }
359
+
360
+ saveState(home, state)
361
+ return out
362
+ }
363
+
364
+ function startForeground(home) {
365
+ const state = loadState(home)
366
+ const sinks = createSinks(home)
367
+ log(home, 'observe started (home=' + home + ', interval=' + INTERVAL_MS + 'ms)')
368
+ const tick = () => {
369
+ try {
370
+ const res = runCycle(home, state, sinks)
371
+ if (res.lines > 0) {
372
+ log(home, 'cycle: files=' + res.files + ' lines=' + res.lines + ' outcomes=' + res.outcomes)
373
+ }
374
+ } catch (err) {
375
+ log(home, 'cycle error: ' + (err && err.stack ? err.stack : String(err)))
376
+ }
377
+ }
378
+ tick()
379
+ setInterval(tick, INTERVAL_MS)
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // background daemon control
384
+ // ---------------------------------------------------------------------------
385
+
386
+ function pidFile(home) {
387
+ return path.join(home, 'observe.pid')
388
+ }
389
+
390
+ function readPid(home) {
391
+ try {
392
+ const pid = Number(fs.readFileSync(pidFile(home), 'utf8').trim())
393
+ return Number.isInteger(pid) && pid > 0 ? pid : null
394
+ } catch (_) {
395
+ return null
396
+ }
397
+ }
398
+
399
+ function isRunning(home) {
400
+ const pid = readPid(home)
401
+ if (!pid) return false
402
+ try {
403
+ process.kill(pid, 0)
404
+ return true
405
+ } catch (_) {
406
+ return false
407
+ }
408
+ }
409
+
410
+ function startBackground(home) {
411
+ if (isRunning(home)) {
412
+ return { running: true, started: false, pid: readPid(home) }
413
+ }
414
+ const script = path.join(__dirname, '..', 'bin', 'mio.js')
415
+ const child = spawn(process.execPath, [script, 'observe'], {
416
+ detached: true,
417
+ stdio: 'ignore',
418
+ windowsHide: true,
419
+ })
420
+ child.unref()
421
+ fs.mkdirSync(home, { recursive: true })
422
+ fs.writeFileSync(pidFile(home), String(child.pid), 'utf8')
423
+ return { running: true, started: true, pid: child.pid }
424
+ }
425
+
426
+ function stopBackground(home) {
427
+ const pid = readPid(home)
428
+ if (pid) {
429
+ try {
430
+ process.kill(pid)
431
+ } catch (_) {}
432
+ try {
433
+ fs.unlinkSync(pidFile(home))
434
+ } catch (_) {}
435
+ }
436
+ return { running: false, stopped: Boolean(pid), pid: pid || null }
437
+ }
438
+
439
+ module.exports = {
440
+ INTERVAL_MS,
441
+ homeDir,
442
+ workbuddyConfigDir,
443
+ loadState,
444
+ saveState,
445
+ runCycle,
446
+ createSinks,
447
+ startForeground,
448
+ startBackground,
449
+ stopBackground,
450
+ isRunning,
451
+ readPid,
452
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mio-agent-runtime",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "Mio Agent Runtime installer and MCP intelligence layer",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -11,10 +11,11 @@
11
11
  "bin/",
12
12
  "server/",
13
13
  "adapters/",
14
+ "observe/",
14
15
  "README.md"
15
16
  ],
16
17
  "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
+ "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",
18
19
  "prepack": "npm run check"
19
20
  },
20
21
  "engines": {